forked from Gitlink/gitlink-cli
Merge PR #310: 贡献者新增图表显示
This commit is contained in:
commit
259b8a1920
|
|
@ -270,5 +270,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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,5 +270,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": "组织"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
|
@ -150,8 +151,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",
|
||||
|
|
@ -317,7 +322,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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue