feat(repo): 贡献者图表支持中文显示

- 为 contributors 命令添加 --chart 参数,支持 bar/pie/table/all 四种图表类型
- 添加 --limit 参数控制显示数量,默认 10 人
- 实现图表中文国际化支持,根据语言设置显示中文或英文
- 修复 limit 参数在 pie 和 table 图表中不生效的问题
- 新增 contributor_charts.go 图表渲染器,支持中文字符对齐
This commit is contained in:
weidongde 2026-07-01 09:10:01 +08:00
parent 982f2cb336
commit e5aba057f8
5 changed files with 839 additions and 5 deletions

View File

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

View File

@ -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": "组织"
}

View File

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

View File

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

View File

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