diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..5e7252e 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -267,5 +267,19 @@ "success.config.set": "✓ {key} = {value}", "warning.auth.not_logged_in": "✗ Not logged in", "warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})", - "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)" + "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)", + "cmd.repo.contributors.short": "List repository contributors", + "flag.contributors.chart": "Display contributors as ASCII chart (bar, pie, table, all)", + "flag.contributors.limit": "Maximum number of contributors to display", + "output.contributors.chart.title": "Contributors Overview", + "output.contributors.chart.summary": "Total Contributors: {total} | Total Contributions: {count}", + "output.contributors.chart.rankings": "Contributor Rankings", + "output.contributors.chart.distribution": "Contribution Distribution", + "output.contributors.chart.list": "Contributors List", + "output.contributors.chart.no_data": "No contributors found", + "error.contributors.chart.unsupported_type": "Unsupported chart type: {type} (use: bar, pie, table, or all)", + "output.contributors.chart.total_contributions": "Total Contributions: {count}", + "output.contributors.chart.table_header": "Rank Name Contributions Type", + "output.contributors.chart.type_user": "User", + "output.contributors.chart.type_organization": "Organization" } diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..a230134 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -267,5 +267,19 @@ "success.config.set": "✓ 已设置 {key} = {value}", "warning.auth.not_logged_in": "✗ 未登录", "warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message})", - "warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)" + "warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)", + "cmd.repo.contributors.short": "列出仓库贡献者", + "flag.contributors.chart": "以 ASCII 图表形式展示贡献者(bar, pie, table, all)", + "flag.contributors.limit": "显示的最大贡献者数量", + "output.contributors.chart.title": "贡献者概览", + "output.contributors.chart.summary": "贡献者总数:{total} | 贡献总数:{count}", + "output.contributors.chart.rankings": "贡献者排行榜", + "output.contributors.chart.distribution": "贡献分布", + "output.contributors.chart.list": "贡献者列表", + "output.contributors.chart.no_data": "未找到贡献者", + "error.contributors.chart.unsupported_type": "不支持的图表类型:{type}(请使用:bar, pie, table 或 all)", + "output.contributors.chart.total_contributions": "贡献总数:{count}", + "output.contributors.chart.table_header": "排名 名称 贡献次数 类型", + "output.contributors.chart.type_user": "用户", + "output.contributors.chart.type_organization": "组织" } diff --git a/shortcuts/repo/contributor_charts.go b/shortcuts/repo/contributor_charts.go new file mode 100644 index 0000000..1e37b63 --- /dev/null +++ b/shortcuts/repo/contributor_charts.go @@ -0,0 +1,412 @@ +package repo + +import ( + "fmt" + "sort" + "strings" + "unicode/utf8" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +// ContributorData represents a single contributor's information +type ContributorData struct { + Login string `json:"login"` + Name string `json:"name"` + Contributions int `json:"contributions"` + Type string `json:"type"` + ImageURL string `json:"image_url"` +} + +// ContributorsResponse represents the API response for contributors +type ContributorsResponse struct { + List []ContributorData `json:"list"` + TotalCount int `json:"total_count"` +} + +// ChartConfig holds configuration for chart rendering +type ChartConfig struct { + Width int + ShowLegend bool + MaxItems int +} + +// displayWidth calculates the display width of a string, accounting for CJK characters +func displayWidth(s string) int { + width := 0 + for _, r := range s { + // CJK characters and fullwidth characters have width 2 + if r >= 0x4E00 && r <= 0x9FFF || // CJK Unified Ideographs + r >= 0x3000 && r <= 0x303F || // CJK Symbols and Punctuation + r >= 0xFF00 && r <= 0xFFEF { // Halfwidth and Fullwidth Forms + width += 2 + } else { + width += 1 + } + } + return width +} + +// padRight pads a string to the specified display width +func padRight(s string, width int) string { + currentWidth := displayWidth(s) + if currentWidth >= width { + return s + } + return s + strings.Repeat(" ", width-currentWidth) +} + +// truncateString truncates a string to fit within the specified display width +func truncateString(s string, maxDisplayWidth int) string { + if displayWidth(s) <= maxDisplayWidth { + return s + } + + result := "" + currentWidth := 0 + for _, r := range s { + rWidth := 1 + if r >= 0x4E00 && r <= 0x9FFF || r >= 0x3000 && r <= 0x303F || r >= 0xFF00 && r <= 0xFFEF { + rWidth = 2 + } + + if currentWidth+rWidth > maxDisplayWidth-3 { + break + } + result += string(r) + currentWidth += rWidth + } + return result + "..." +} + +// RenderContributorsChart renders an ASCII chart for contributors +func RenderContributorsChart(data *ContributorsResponse, config ChartConfig, tr *i18n.Translator) string { + if data == nil || len(data.List) == 0 { + return " " + tr.T("output.contributors.chart.no_data") + } + + // Limit the number of items to display + items := data.List + if config.MaxItems > 0 && len(items) > config.MaxItems { + items = items[:config.MaxItems] + } + + // Calculate total contributions + totalContributions := 0 + for _, c := range items { + totalContributions += c.Contributions + } + + var sections []string + + // Header + sections = append(sections, "") + sections = append(sections, " "+tr.T("output.contributors.chart.title")) + sections = append(sections, " "+strings.Repeat("-", 60)) + + // Summary line + sections = append(sections, " "+tr.Tf("output.contributors.chart.summary", i18n.Args{"total": data.TotalCount, "count": totalContributions})) + sections = append(sections, "") + + // Bar chart + barChart := renderBarChart(items, config.Width, totalContributions) + sections = append(sections, barChart) + + return strings.Join(sections, "\n") +} + +// renderBarChart renders a simple horizontal bar chart +func renderBarChart(contributors []ContributorData, width int, total int) string { + if len(contributors) == 0 { + return "" + } + + // Sort by contributions (descending) + sorted := make([]ContributorData, len(contributors)) + copy(sorted, contributors) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Contributions > sorted[j].Contributions + }) + + // Find max contribution for scaling + maxContrib := sorted[0].Contributions + + // Calculate max name display width for alignment + maxNameWidth := 0 + for _, c := range sorted { + name := getDisplayName(c) + w := displayWidth(name) + if w > maxNameWidth { + maxNameWidth = w + } + } + + // Limit name width + if maxNameWidth > 20 { + maxNameWidth = 20 + } + + barWidth := 30 + + var lines []string + + // Render each contributor + for i, c := range sorted { + name := getDisplayName(c) + name = truncateString(name, maxNameWidth) + name = padRight(name, maxNameWidth) + + // Calculate bar length + barLen := 0 + if maxContrib > 0 { + barLen = int(float64(c.Contributions) / float64(maxContrib) * float64(barWidth)) + } + if barLen < 1 && c.Contributions > 0 { + barLen = 1 + } + + // Calculate percentage + percentage := 0.0 + if total > 0 { + percentage = float64(c.Contributions) / float64(total) * 100 + } + + // Create bar + bar := strings.Repeat("#", barLen) + bar = padRight(bar, barWidth) + + // Format: rank. name | bar | count (percentage) + line := fmt.Sprintf(" %2d. %s | %s | %6d (%5.1f%%)", + i+1, + name, + bar, + c.Contributions, + percentage, + ) + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +// getDisplayName returns the display name for a contributor +func getDisplayName(c ContributorData) string { + if c.Name != "" { + return c.Name + } + return c.Login +} + +// RenderPieChart renders a simple percentage distribution chart +func RenderPieChart(contributors []ContributorData, width int, tr *i18n.Translator) string { + if len(contributors) == 0 { + return "" + } + + // Calculate total + total := 0 + for _, c := range contributors { + total += c.Contributions + } + + if total == 0 { + return "" + } + + // Sort by contributions + sorted := make([]ContributorData, len(contributors)) + copy(sorted, contributors) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Contributions > sorted[j].Contributions + }) + + var lines []string + + // Header + lines = append(lines, "") + lines = append(lines, " "+tr.T("output.contributors.chart.distribution")) + lines = append(lines, " "+strings.Repeat("-", 60)) + lines = append(lines, "") + + barWidth := 25 + + // Calculate max name display width + maxNameWidth := 0 + for _, c := range sorted { + name := getDisplayName(c) + w := displayWidth(name) + if w > maxNameWidth { + maxNameWidth = w + } + } + if maxNameWidth > 20 { + maxNameWidth = 20 + } + + // Render percentage bars + for _, c := range sorted { + name := getDisplayName(c) + name = truncateString(name, maxNameWidth) + name = padRight(name, maxNameWidth) + + percentage := float64(c.Contributions) / float64(total) * 100 + + // Create percentage bar + filled := int(percentage / 100 * float64(barWidth)) + if filled < 1 && c.Contributions > 0 { + filled = 1 + } + + bar := strings.Repeat("#", filled) + strings.Repeat("-", barWidth-filled) + + line := fmt.Sprintf(" %s | %s | %5.1f%%", + name, + bar, + percentage, + ) + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +// RenderContributorsTable renders a simple table view of contributors +func RenderContributorsTable(contributors []ContributorData, tr *i18n.Translator) string { + if len(contributors) == 0 { + return " " + tr.T("output.contributors.chart.no_data") + } + + // Sort by contributions + sorted := make([]ContributorData, len(contributors)) + copy(sorted, contributors) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Contributions > sorted[j].Contributions + }) + + var lines []string + + // Header + lines = append(lines, "") + lines = append(lines, " "+tr.T("output.contributors.chart.list")) + lines = append(lines, " "+strings.Repeat("-", 60)) + lines = append(lines, "") + + // Table header + lines = append(lines, " "+tr.T("output.contributors.chart.table_header")) + lines = append(lines, " "+strings.Repeat("-", 60)) + + // Table rows + for i, c := range sorted { + name := getDisplayName(c) + name = truncateString(name, 20) + name = padRight(name, 20) + + userType := c.Type + if userType == "" || userType == "User" { + userType = tr.T("output.contributors.chart.type_user") + } else if userType == "Organization" { + userType = tr.T("output.contributors.chart.type_organization") + } + + line := fmt.Sprintf(" %-4d %s %13d %s", + i+1, + name, + c.Contributions, + userType, + ) + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +// RenderDonutChart renders a simple donut chart (placeholder) +func RenderDonutChart(contributors []ContributorData, tr *i18n.Translator) string { + if len(contributors) == 0 { + return "" + } + + // Calculate total + total := 0 + for _, c := range contributors { + total += c.Contributions + } + + if total == 0 { + return "" + } + + return " " + tr.Tf("output.contributors.chart.total_contributions", i18n.Args{"count": total}) +} + +// RenderSparkline renders a sparkline for contribution trends +func RenderSparkline(contributors []ContributorData) string { + if len(contributors) == 0 { + return "" + } + + // Sort by contributions + sorted := make([]ContributorData, len(contributors)) + copy(sorted, contributors) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Contributions > sorted[j].Contributions + }) + + // Get top 10 contributions + values := []int{} + for i, c := range sorted { + if i >= 10 { + break + } + values = append(values, c.Contributions) + } + + if len(values) == 0 { + return "" + } + + max := float64(values[0]) + if max == 0 { + max = 1 + } + + // Sparkline characters + sparkChars := []string{"_", ".", ":", "|", "!", "#", "$", "@"} + + sparkline := "" + for _, v := range values { + normalized := int(float64(v) / max * 7) + if normalized < 0 { + normalized = 0 + } + if normalized > 7 { + normalized = 7 + } + sparkline += sparkChars[normalized] + " " + } + + return sparkline +} + +// stripANSI removes ANSI escape codes from a string +func stripANSI(s string) string { + result := "" + inEscape := false + for _, c := range s { + if c == '\033' { + inEscape = true + continue + } + if inEscape { + if c == 'm' { + inEscape = false + } + continue + } + result += string(c) + } + return result +} + +// Unused import guard +var _ = utf8.RuneLen diff --git a/shortcuts/repo/contributor_charts_test.go b/shortcuts/repo/contributor_charts_test.go new file mode 100644 index 0000000..52fe523 --- /dev/null +++ b/shortcuts/repo/contributor_charts_test.go @@ -0,0 +1,332 @@ +package repo + +import ( + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func TestRenderContributorsChart(t *testing.T) { + tests := []struct { + name string + data *ContributorsResponse + config ChartConfig + contains []string + empty bool + }{ + { + name: "nil data", + data: nil, + config: ChartConfig{ + Width: 60, + MaxItems: 10, + }, + empty: true, + }, + { + name: "empty list", + data: &ContributorsResponse{ + List: []ContributorData{}, + TotalCount: 0, + }, + config: ChartConfig{ + Width: 60, + MaxItems: 10, + }, + empty: true, + }, + { + name: "single contributor", + data: &ContributorsResponse{ + List: []ContributorData{ + {Login: "user1", Name: "User One", Contributions: 100, Type: "User"}, + }, + TotalCount: 1, + }, + config: ChartConfig{ + Width: 60, + MaxItems: 10, + }, + contains: []string{"Contributors", "User One", "100"}, + }, + { + name: "multiple contributors", + data: &ContributorsResponse{ + List: []ContributorData{ + {Login: "user1", Name: "User One", Contributions: 100, Type: "User"}, + {Login: "user2", Name: "User Two", Contributions: 50, Type: "User"}, + {Login: "user3", Name: "User Three", Contributions: 25, Type: "User"}, + }, + TotalCount: 3, + }, + config: ChartConfig{ + Width: 60, + MaxItems: 10, + }, + contains: []string{"Contributors", "User One", "User Two", "User Three"}, + }, + { + name: "limit max items", + data: &ContributorsResponse{ + List: []ContributorData{ + {Login: "user1", Contributions: 100}, + {Login: "user2", Contributions: 90}, + {Login: "user3", Contributions: 80}, + {Login: "user4", Contributions: 70}, + {Login: "user5", Contributions: 60}, + }, + TotalCount: 5, + }, + config: ChartConfig{ + Width: 60, + MaxItems: 2, + }, + contains: []string{"user1", "user2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RenderContributorsChart(tt.data, tt.config, i18n.Default()) + + if tt.empty { + if !strings.Contains(result, "No contributors found") { + t.Errorf("expected result to contain 'No contributors found', got %q", result) + } + return + } + + for _, s := range tt.contains { + if !strings.Contains(result, s) { + t.Errorf("expected result to contain %q, but it didn't\nResult:\n%s", s, result) + } + } + }) + } +} + +func TestRenderPieChart(t *testing.T) { + tests := []struct { + name string + data []ContributorData + contains []string + empty bool + }{ + { + name: "empty list", + data: []ContributorData{}, + empty: true, + }, + { + name: "single contributor", + data: []ContributorData{ + {Login: "user1", Contributions: 100}, + }, + contains: []string{"Contribution Distribution", "user1", "100.0%"}, + }, + { + name: "multiple contributors", + data: []ContributorData{ + {Login: "user1", Contributions: 100}, + {Login: "user2", Contributions: 50}, + {Login: "user3", Contributions: 25}, + }, + contains: []string{"user1", "user2", "user3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RenderPieChart(tt.data, 60, i18n.Default()) + + if tt.empty { + if result != "" { + t.Errorf("expected empty result, got %q", result) + } + return + } + + for _, s := range tt.contains { + if !strings.Contains(result, s) { + t.Errorf("expected result to contain %q, but it didn't\nResult:\n%s", s, result) + } + } + }) + } +} + +func TestRenderContributorsTable(t *testing.T) { + tests := []struct { + name string + data []ContributorData + contains []string + empty bool + }{ + { + name: "empty list", + data: []ContributorData{}, + empty: true, + }, + { + name: "single contributor", + data: []ContributorData{ + {Login: "user1", Name: "User One", Contributions: 100, Type: "User"}, + }, + contains: []string{"Contributors List", "User One", "100"}, + }, + { + name: "multiple contributors", + data: []ContributorData{ + {Login: "user1", Contributions: 100, Type: "User"}, + {Login: "user2", Contributions: 50, Type: "User"}, + }, + contains: []string{"Rank", "Name", "Contributions", "Type"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RenderContributorsTable(tt.data, i18n.Default()) + + if tt.empty { + if !strings.Contains(result, "No contributors found") { + t.Errorf("expected result to contain 'No contributors found', got %q", result) + } + return + } + + for _, s := range tt.contains { + if !strings.Contains(result, s) { + t.Errorf("expected result to contain %q, but it didn't\nResult:\n%s", s, result) + } + } + }) + } +} + +func TestRenderDonutChart(t *testing.T) { + tests := []struct { + name string + data []ContributorData + contains []string + empty bool + }{ + { + name: "empty list", + data: []ContributorData{}, + empty: true, + }, + { + name: "with contributors", + data: []ContributorData{ + {Login: "user1", Contributions: 100}, + {Login: "user2", Contributions: 50}, + }, + contains: []string{"Contributions", "150"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RenderDonutChart(tt.data, i18n.Default()) + + if tt.empty { + if result != "" { + t.Errorf("expected empty result, got %q", result) + } + return + } + + for _, s := range tt.contains { + if !strings.Contains(result, s) { + t.Errorf("expected result to contain %q, but it didn't\nResult:\n%s", s, result) + } + } + }) + } +} + +func TestRenderSparkline(t *testing.T) { + tests := []struct { + name string + data []ContributorData + empty bool + minChars int + }{ + { + name: "empty list", + data: []ContributorData{}, + empty: true, + }, + { + name: "single contributor", + data: []ContributorData{ + {Login: "user1", Contributions: 100}, + }, + minChars: 1, + }, + { + name: "multiple contributors", + data: []ContributorData{ + {Login: "user1", Contributions: 100}, + {Login: "user2", Contributions: 80}, + {Login: "user3", Contributions: 60}, + {Login: "user4", Contributions: 40}, + {Login: "user5", Contributions: 20}, + }, + minChars: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RenderSparkline(tt.data) + + if tt.empty { + if result != "" { + t.Errorf("expected empty result, got %q", result) + } + return + } + + if len(result) < tt.minChars { + t.Errorf("expected at least %d sparkline characters, got %d", tt.minChars, len(result)) + } + }) + } +} + +func TestGetDisplayName(t *testing.T) { + tests := []struct { + name string + input ContributorData + expected string + }{ + { + name: "with name", + input: ContributorData{ + Login: "user1", + Name: "User One", + }, + expected: "User One", + }, + { + name: "without name", + input: ContributorData{ + Login: "user1", + Name: "", + }, + expected: "user1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getDisplayName(tt.input) + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 06774a6..8bf19bf 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -1,6 +1,7 @@ package repo import ( + "encoding/json" "fmt" "net/url" "strconv" @@ -115,8 +116,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "contributors", - Description: "List repository contributors", - Run: runContributors, + Description: tr.T("cmd.repo.contributors.short"), + Flags: []common.Flag{ + {Name: "chart", Short: "c", Usage: tr.T("flag.contributors.chart"), Default: ""}, + {Name: "limit", Short: "l", Usage: tr.T("flag.contributors.limit"), Default: "10"}, + }, + Run: runContributors, }, { Name: "contributor-stats", @@ -282,7 +287,64 @@ func runContributors(ctx *common.RuntimeContext) error { if err != nil { return err } - return ctx.Output(env) + + // Check if chart mode is requested + chartType := ctx.Arg("chart") + if chartType == "" { + // Default: output as JSON/table + return ctx.Output(env) + } + + // Parse the response into ContributorsResponse + // env.Data contains the API response + dataBytes, err := json.Marshal(env.Data) + if err != nil { + return fmt.Errorf("failed to marshal response data: %w", err) + } + + var resp ContributorsResponse + if err := json.Unmarshal(dataBytes, &resp); err != nil { + return fmt.Errorf("failed to parse contributors response: %w", err) + } + + // Parse limit + limit := 10 + if l := ctx.Arg("limit"); l != "" { + if val, err := strconv.Atoi(l); err == nil && val > 0 { + limit = val + } + } + + // Render chart based on type + config := ChartConfig{ + Width: 80, + MaxItems: limit, + } + + // Apply limit to list for pie and table charts + limitedList := resp.List + if limit > 0 && len(limitedList) > limit { + limitedList = limitedList[:limit] + } + + var output string + switch strings.ToLower(chartType) { + case "bar": + output = RenderContributorsChart(&resp, config, ctx.Tr) + case "pie": + output = RenderPieChart(limitedList, config.Width, ctx.Tr) + case "table": + output = RenderContributorsTable(limitedList, ctx.Tr) + case "all": + output = RenderContributorsChart(&resp, config, ctx.Tr) + "\n\n" + + RenderPieChart(limitedList, config.Width, ctx.Tr) + "\n\n" + + RenderContributorsTable(limitedList, ctx.Tr) + default: + return fmt.Errorf("unsupported chart type: %s (use: bar, pie, table, or all)", chartType) + } + + fmt.Println(output) + return nil } func runContributorStats(ctx *common.RuntimeContext) error {