diff --git a/README.md b/README.md index 58cb831..2dc7d73 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,29 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### User Analytics + +```bash +# Show contribution heatmap for a specific year +gitlink-cli user +headmap --login Mengz --year 2026 + +# Show recent activity timeline +gitlink-cli user +activity --login Mengz + +# Show development capability scores and language distribution +gitlink-cli user +develop --login Mengz --start-time 1735689600 --end-time 1767225599 + +# Show role distribution across projects +gitlink-cli user +roles --login Mengz + +# Show major domain categories +gitlink-cli user +majors --login Mengz + +# Show project trends with cross-page filtering +gitlink-cli user +trends --login Mengz --trend-type PullRequest +gitlink-cli user +trends --login Mengz --keyword release --project gitlink-cli +``` + ### Workflow Agent Commands `workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports: diff --git a/doc/changes/user-analytics-shortcuts.md b/doc/changes/user-analytics-shortcuts.md new file mode 100644 index 0000000..2f9e6cf --- /dev/null +++ b/doc/changes/user-analytics-shortcuts.md @@ -0,0 +1,29 @@ +# User Analytics Shortcuts + +This change expands the `user` shortcut group beyond basic profile lookup and +adds a read-only analytics toolkit for contribution and activity inspection. + +New commands: + +- `user +headmap` +- `user +activity` +- `user +develop` +- `user +roles` +- `user +majors` +- `user +trends` + +Highlights: + +- Adds input validation for year, page, limit, and Unix timestamp ranges. +- Normalizes analytics responses into stable, script-friendly structures. +- Summarizes activity totals, peak days, language distribution, and primary roles. +- Supports cross-page trend filtering by type, project owner, project, and keyword. + +Validation: + +- `go test ./shortcuts/user/...` +- `go test ./shortcuts/...` +- `go test ./...` +- `go build ./...` +- `go run . user +headmap --login Mengz --format json` +- `go run . user +trends --login Mengz --trend-type PullRequest --limit 2 --format json` diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index 2f8be55..4486359 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -3,6 +3,7 @@ package user import ( "fmt" "net/url" + "sort" "strconv" "strings" @@ -35,7 +36,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", url.PathEscape(login)), nil) if err != nil { return err } @@ -43,150 +44,786 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "activity", - Description: "Show recent user activity statistics", - Flags: userLoginFlags(), + Name: "headmap", + Description: "Show user contribution heatmap and yearly totals", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "year", Usage: "Contribution year, for example 2026"}, + }, Run: func(ctx *common.RuntimeContext) error { - return runUserStats(ctx, "statistics/activity", false) + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + query, err := buildHeadmapQuery(ctx.Arg("year")) + if err != nil { + return err + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/headmaps", url.PathEscape(login)), query) + if err != nil { + return err + } + data, err := normalizeHeadmapData(login, ctx.Arg("year"), env.Data) + if err != nil { + return err + } + return ctx.OutputData(data) }, }, { - Name: "headmap", - Description: "Show user contribution heatmap data", - Flags: append(userLoginFlags(), common.Flag{ - Name: "year", Short: "y", Usage: "Contribution year, for example 2026", - }), - Run: runHeadmap, + Name: "activity", + Description: "Show recent user activity timeline across commits, issues, and pull requests", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/activity", url.PathEscape(login)), nil) + if err != nil { + return err + } + data, err := normalizeActivityData(login, env.Data) + if err != nil { + return err + } + return ctx.OutputData(data) + }, }, { Name: "develop", - Description: "Show user development capability statistics", - Flags: userStatisticsRangeFlags(), + Description: "Show user development capability scores and language distribution", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "start-time", Usage: "Start Unix timestamp"}, + {Name: "end-time", Usage: "End Unix timestamp"}, + }, Run: func(ctx *common.RuntimeContext) error { - return runUserStats(ctx, "statistics/develop", true) + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + query, period, err := buildTimeRangeQuery(ctx.Arg("start-time"), ctx.Arg("end-time")) + if err != nil { + return err + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/statistics/develop", url.PathEscape(login)), query) + if err != nil { + return err + } + data, err := normalizeDevelopData(login, period, env.Data) + if err != nil { + return err + } + return ctx.OutputData(data) }, }, { - Name: "role", - Description: "Show user role distribution statistics", - Flags: userStatisticsRangeFlags(), + Name: "roles", + Description: "Show user project role distribution", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "start-time", Usage: "Start Unix timestamp"}, + {Name: "end-time", Usage: "End Unix timestamp"}, + }, Run: func(ctx *common.RuntimeContext) error { - return runUserStats(ctx, "statistics/role", true) + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + query, period, err := buildTimeRangeQuery(ctx.Arg("start-time"), ctx.Arg("end-time")) + if err != nil { + return err + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/statistics/role", url.PathEscape(login)), query) + if err != nil { + return err + } + data, err := normalizeRoleData(login, period, env.Data) + if err != nil { + return err + } + return ctx.OutputData(data) }, }, { - Name: "major", - Description: "Show user professional category statistics", - Flags: userStatisticsRangeFlags(), + Name: "majors", + Description: "Show user major domain categories", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "start-time", Usage: "Start Unix timestamp"}, + {Name: "end-time", Usage: "End Unix timestamp"}, + }, Run: func(ctx *common.RuntimeContext) error { - return runUserStats(ctx, "statistics/major", true) + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + query, period, err := buildTimeRangeQuery(ctx.Arg("start-time"), ctx.Arg("end-time")) + if err != nil { + return err + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/statistics/major", url.PathEscape(login)), query) + if err != nil { + return err + } + data, err := normalizeMajorData(login, period, env.Data) + if err != nil { + return err + } + return ctx.OutputData(data) + }, + }, + { + Name: "trends", + Description: "Show user project trends with optional cross-page filtering", + Flags: []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "L", Usage: "Items per page", Default: "20"}, + {Name: "all", Usage: "Fetch all pages before filtering", Bool: true, Default: "false"}, + {Name: "trend-type", Usage: "Trend type filter, for example PullRequest or CommitLog"}, + {Name: "project-owner", Usage: "Project owner login filter"}, + {Name: "project", Usage: "Project identifier filter"}, + {Name: "keyword", Usage: "Keyword filter applied to trend title and action type"}, + }, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + page, err := parsePositiveInt("page", ctx.Arg("page")) + if err != nil { + return err + } + limit, err := parsePositiveInt("limit", ctx.Arg("limit")) + if err != nil { + return err + } + filters := trendFilters{ + TrendType: strings.TrimSpace(ctx.Arg("trend-type")), + ProjectOwner: strings.TrimSpace(ctx.Arg("project-owner")), + Project: strings.TrimSpace(ctx.Arg("project")), + Keyword: strings.TrimSpace(ctx.Arg("keyword")), + } + fetchAll := parseBoolArg(ctx.Arg("all")) || filters.enabled() + items, totalCount, err := fetchUserTrends(ctx, login, page, limit, fetchAll) + if err != nil { + return err + } + data, err := normalizeTrendData(login, page, limit, totalCount, fetchAll, filters, items) + if err != nil { + return err + } + return ctx.OutputData(data) }, }, } } -func userLoginFlags() []common.Flag { - return []common.Flag{{Name: "login", Short: "l", Usage: "User login; defaults to --owner or current user"}} +type trendFilters struct { + TrendType string + ProjectOwner string + Project string + Keyword string } -func userStatisticsRangeFlags() []common.Flag { - return append(userLoginFlags(), - common.Flag{Name: "start-time", Usage: "Start Unix timestamp"}, - common.Flag{Name: "end-time", Usage: "End Unix timestamp"}, - ) +func (f trendFilters) enabled() bool { + return f.TrendType != "" || f.ProjectOwner != "" || f.Project != "" || f.Keyword != "" } -func runHeadmap(ctx *common.RuntimeContext) error { - login, err := resolveUserLogin(ctx) - if err != nil { - return err - } +func buildHeadmapQuery(year string) (url.Values, error) { q := url.Values{} - if year := strings.TrimSpace(ctx.Arg("year")); year != "" { - if err := validateYear(year); err != nil { - return err - } - q.Set("year", year) + if strings.TrimSpace(year) == "" { + return q, nil } - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/headmaps", login), q) + parsed, err := parsePositiveInt("year", year) if err != nil { - return err + return nil, err } - return ctx.Output(env) + q.Set("year", strconv.Itoa(parsed)) + return q, nil } -func runUserStats(ctx *common.RuntimeContext, suffix string, withRange bool) error { - login, err := resolveUserLogin(ctx) - if err != nil { - return err - } +func buildTimeRangeQuery(start, end string) (url.Values, map[string]interface{}, error) { q := url.Values{} - if withRange { - start, hasStart, err := addTimestampQuery(q, "start_time", "start-time", ctx.Arg("start-time")) + period := map[string]interface{}{} + + if strings.TrimSpace(start) != "" { + parsed, err := parsePositiveInt("start-time", start) if err != nil { - return err + return nil, nil, err } - end, hasEnd, err := addTimestampQuery(q, "end_time", "end-time", ctx.Arg("end-time")) + q.Set("start_time", strconv.Itoa(parsed)) + period["start_time"] = parsed + } + if strings.TrimSpace(end) != "" { + parsed, err := parsePositiveInt("end-time", end) if err != nil { - return err + return nil, nil, err } - if hasStart && hasEnd && start > end { - return fmt.Errorf("start-time must be less than or equal to end-time") + q.Set("end_time", strconv.Itoa(parsed)) + period["end_time"] = parsed + } + if startValue, ok := period["start_time"].(int); ok { + if endValue, ok := period["end_time"].(int); ok && startValue > endValue { + return nil, nil, fmt.Errorf("--start-time must be less than or equal to --end-time") } } - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/%s", login, suffix), q) + if len(period) == 0 { + return q, nil, nil + } + return q, period, nil +} + +func parsePositiveInt(flagName, value string) (int, error) { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("--%s must be a positive integer", flagName) + } + return parsed, nil +} + +func parseBoolArg(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + +func fetchUserTrends(ctx *common.RuntimeContext, login string, page, limit int, fetchAll bool) ([]interface{}, int, error) { + if !fetchAll { + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/project_trends", url.PathEscape(login)), url.Values{ + "page": []string{strconv.Itoa(page)}, + "limit": []string{strconv.Itoa(limit)}, + }) + if err != nil { + return nil, 0, err + } + return unwrapTrendResponse(env.Data) + } + + var all []interface{} + totalCount := 0 + currentPage := 1 + + for { + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/project_trends", url.PathEscape(login)), url.Values{ + "page": []string{strconv.Itoa(currentPage)}, + "limit": []string{strconv.Itoa(limit)}, + }) + if err != nil { + return nil, 0, err + } + items, count, err := unwrapTrendResponse(env.Data) + if err != nil { + return nil, 0, err + } + if totalCount == 0 { + totalCount = count + } + if len(items) == 0 { + break + } + all = append(all, items...) + if len(items) < limit || (totalCount > 0 && len(all) >= totalCount) { + break + } + currentPage++ + } + + return all, totalCount, nil +} + +func unwrapTrendResponse(data interface{}) ([]interface{}, int, error) { + m, err := asMap(data) if err != nil { - return err + return nil, 0, err } - return ctx.Output(env) -} - -func resolveUserLogin(ctx *common.RuntimeContext) (string, error) { - if login := strings.TrimSpace(ctx.Arg("login")); login != "" { - return login, nil - } - if owner := strings.TrimSpace(ctx.Owner); owner != "" { - return owner, nil - } - env, err := ctx.CallAPI("GET", "/users/me", nil) + items, err := asSlice(m["project_trends"]) if err != nil { - return "", fmt.Errorf("resolve current user: %w", err) + return nil, 0, err } - data, _ := env.Data.(map[string]interface{}) - login, _ := data["login"].(string) - if login == "" { - return "", fmt.Errorf("cannot determine current user login; pass --login") - } - return login, nil + return items, asInt(m["total_count"]), nil } -func addTimestampQuery(q url.Values, queryName, flagName, value string) (int64, bool, error) { - value = strings.TrimSpace(value) - if value == "" { - return 0, false, nil - } - n, err := parseNonNegativeInt(flagName, value) +func normalizeHeadmapData(login, year string, data interface{}) (map[string]interface{}, error) { + m, err := asMap(data) if err != nil { - return 0, false, err + return nil, err } - q.Set(queryName, value) - return n, true, nil + items, err := asSlice(m["headmaps"]) + if err != nil { + return nil, err + } + + headmaps := make([]map[string]interface{}, 0, len(items)) + activeDays := 0 + busiest := map[string]interface{}{} + maxContributions := -1 + + for _, item := range items { + entry, err := asMap(item) + if err != nil { + return nil, err + } + normalized := map[string]interface{}{ + "date": strings.TrimSpace(asString(entry["date"])), + "contributions": asInt(entry["contributions"]), + } + headmaps = append(headmaps, normalized) + if normalized["contributions"].(int) > 0 { + activeDays++ + } + if normalized["contributions"].(int) > maxContributions { + maxContributions = normalized["contributions"].(int) + busiest = map[string]interface{}{ + "date": normalized["date"], + "contributions": normalized["contributions"], + } + } + } + + result := map[string]interface{}{ + "login": login, + "total_contributions": asInt(m["total_contributions"]), + "active_days": activeDays, + "headmaps": headmaps, + } + if strings.TrimSpace(year) != "" { + result["year"] = asInt(year) + } + if len(busiest) > 0 { + result["busiest_day"] = busiest + } + return result, nil } -func parseNonNegativeInt(name, value string) (int64, error) { - n, err := strconv.ParseInt(value, 10, 64) - if err != nil || n < 0 { - return 0, fmt.Errorf("%s must be a non-negative integer", name) +func normalizeActivityData(login string, data interface{}) (map[string]interface{}, error) { + m, err := asMap(data) + if err != nil { + return nil, err } - return n, nil + dates := asStringSlice(m["dates"]) + commits := asIntSlice(m["commits_count"]) + issues := asIntSlice(m["issues_count"]) + prs := asIntSlice(m["pull_requests_count"]) + + seriesLength := len(dates) + if len(commits) > seriesLength { + seriesLength = len(commits) + } + if len(issues) > seriesLength { + seriesLength = len(issues) + } + if len(prs) > seriesLength { + seriesLength = len(prs) + } + + timeline := make([]map[string]interface{}, 0, seriesLength) + totalCommits := 0 + totalIssues := 0 + totalPRs := 0 + peakDay := map[string]interface{}{} + peakTotal := -1 + + for i := 0; i < seriesLength; i++ { + entry := map[string]interface{}{ + "date": stringAt(dates, i), + "commits": intAt(commits, i), + "issues": intAt(issues, i), + "pull_requests": intAt(prs, i), + } + entry["total"] = entry["commits"].(int) + entry["issues"].(int) + entry["pull_requests"].(int) + totalCommits += entry["commits"].(int) + totalIssues += entry["issues"].(int) + totalPRs += entry["pull_requests"].(int) + if entry["total"].(int) > peakTotal { + peakTotal = entry["total"].(int) + peakDay = entry + } + timeline = append(timeline, entry) + } + + return map[string]interface{}{ + "login": login, + "period_days": len(timeline), + "totals": map[string]interface{}{ + "commits": totalCommits, + "issues": totalIssues, + "pull_requests": totalPRs, + "all": totalCommits + totalIssues + totalPRs, + }, + "peak_day": peakDay, + "timeline": timeline, + }, nil } -func validateYear(value string) error { - n, err := strconv.Atoi(value) - if err != nil || n < 1970 || n > 9999 { - return fmt.Errorf("year must be a four-digit year") +func normalizeDevelopData(login string, period map[string]interface{}, data interface{}) (map[string]interface{}, error) { + m, err := asMap(data) + if err != nil { + return nil, err } - return nil + platform, err := asMap(m["platform"]) + if err != nil { + return nil, err + } + userScores, err := asMap(m["user"]) + if err != nil { + return nil, err + } + languages := mergeLanguageStats(userScores["languages_percent"], userScores["each_language_score"]) + + result := map[string]interface{}{ + "login": login, + "platform_scores": normalizeScoreMap(platform), + "user_scores": normalizeScoreMap(userScores), + "languages": languages, + } + if len(languages) > 0 { + result["top_language"] = languages[0] + } + if period != nil { + result["period"] = period + } + return result, nil +} + +func normalizeRoleData(login string, period map[string]interface{}, data interface{}) (map[string]interface{}, error) { + m, err := asMap(data) + if err != nil { + return nil, err + } + roleMap, err := asMap(m["role"]) + if err != nil { + return nil, err + } + roles := make([]map[string]interface{}, 0, len(roleMap)) + for name, value := range roleMap { + entry, err := asMap(value) + if err != nil { + return nil, err + } + roles = append(roles, map[string]interface{}{ + "name": name, + "count": asInt(entry["count"]), + "percent": asFloat(entry["percent"]), + }) + } + sort.Slice(roles, func(i, j int) bool { + if roles[i]["count"].(int) == roles[j]["count"].(int) { + return roles[i]["name"].(string) < roles[j]["name"].(string) + } + return roles[i]["count"].(int) > roles[j]["count"].(int) + }) + + result := map[string]interface{}{ + "login": login, + "total_projects_count": asInt(m["total_projects_count"]), + "roles": roles, + } + if len(roles) > 0 { + result["primary_role"] = roles[0] + } + if period != nil { + result["period"] = period + } + return result, nil +} + +func normalizeMajorData(login string, period map[string]interface{}, data interface{}) (map[string]interface{}, error) { + m, err := asMap(data) + if err != nil { + return nil, err + } + categories := asStringSlice(m["categories"]) + result := map[string]interface{}{ + "login": login, + "category_count": len(categories), + "categories": categories, + } + if len(categories) > 0 { + result["primary_category"] = categories[0] + } + if period != nil { + result["period"] = period + } + return result, nil +} + +func normalizeTrendData(login string, page, limit, totalCount int, fetchedAll bool, filters trendFilters, items []interface{}) (map[string]interface{}, error) { + normalized := make([]map[string]interface{}, 0, len(items)) + countsByType := map[string]int{} + + for _, item := range items { + entry, err := normalizeTrendItem(item) + if err != nil { + return nil, err + } + if !matchesTrendFilters(entry, filters) { + continue + } + countsByType[entry["trend_type"].(string)]++ + normalized = append(normalized, entry) + } + + result := map[string]interface{}{ + "login": login, + "page": page, + "limit": limit, + "fetched_all": fetchedAll, + "total_count": totalCount, + "matched_count": len(normalized), + "counts_by_type": countsByType, + "items": normalized, + } + if filters.enabled() { + filterMap := map[string]interface{}{} + if filters.TrendType != "" { + filterMap["trend_type"] = filters.TrendType + } + if filters.ProjectOwner != "" { + filterMap["project_owner"] = filters.ProjectOwner + } + if filters.Project != "" { + filterMap["project"] = filters.Project + } + if filters.Keyword != "" { + filterMap["keyword"] = filters.Keyword + } + result["filters"] = filterMap + } + return result, nil +} + +func normalizeTrendItem(item interface{}) (map[string]interface{}, error) { + m, err := asMap(item) + if err != nil { + return nil, err + } + project, _ := asMap(m["project"]) + projectOwner, _ := asMap(project["owner"]) + commitLog, _ := asMap(m["commit_log"]) + + entry := map[string]interface{}{ + "id": asInt(m["id"]), + "trend_id": asInt(m["trend_id"]), + "trend_type": strings.TrimSpace(asString(m["trend_type"])), + "name": strings.TrimSpace(asString(m["name"])), + "action_type": strings.TrimSpace(asString(m["action_type"])), + "action_time": strings.TrimSpace(asString(m["action_time"])), + "created_at": strings.TrimSpace(asString(m["created_at"])), + "user_login": strings.TrimSpace(asString(m["user_login"])), + "user_name": strings.TrimSpace(asString(m["user_name"])), + "project_identifier": strings.TrimSpace(asString(project["identifier"])), + "project_owner": strings.TrimSpace(asString(projectOwner["login"])), + } + if description := strings.TrimSpace(asString(project["description"])); description != "" { + entry["project_description"] = description + } + if ref := strings.TrimSpace(asString(commitLog["ref"])); ref != "" { + entry["commit_ref"] = ref + } + if commitID := strings.TrimSpace(asString(commitLog["commit_id"])); commitID != "" { + entry["commit_id"] = commitID + } + return entry, nil +} + +func matchesTrendFilters(entry map[string]interface{}, filters trendFilters) bool { + if filters.TrendType != "" && !strings.EqualFold(entry["trend_type"].(string), filters.TrendType) { + return false + } + if filters.ProjectOwner != "" && !strings.EqualFold(entry["project_owner"].(string), filters.ProjectOwner) { + return false + } + if filters.Project != "" && !strings.EqualFold(entry["project_identifier"].(string), filters.Project) { + return false + } + if filters.Keyword != "" { + haystack := strings.ToLower(strings.Join([]string{ + entry["name"].(string), + entry["action_type"].(string), + entry["project_identifier"].(string), + }, " ")) + if !strings.Contains(haystack, strings.ToLower(filters.Keyword)) { + return false + } + } + return true +} + +func mergeLanguageStats(percentValue interface{}, scoreValue interface{}) []map[string]interface{} { + percentMap := asFloatMap(percentValue) + scoreMap := asIntMap(scoreValue) + keys := map[string]struct{}{} + for key := range percentMap { + keys[key] = struct{}{} + } + for key := range scoreMap { + keys[key] = struct{}{} + } + + languages := make([]map[string]interface{}, 0, len(keys)) + for key := range keys { + languages = append(languages, map[string]interface{}{ + "name": key, + "percent": percentMap[key], + "score": scoreMap[key], + }) + } + sort.Slice(languages, func(i, j int) bool { + if languages[i]["percent"].(float64) == languages[j]["percent"].(float64) { + return languages[i]["name"].(string) < languages[j]["name"].(string) + } + return languages[i]["percent"].(float64) > languages[j]["percent"].(float64) + }) + return languages +} + +func normalizeScoreMap(values map[string]interface{}) map[string]interface{} { + scores := map[string]interface{}{} + for key, value := range values { + if key == "languages_percent" || key == "each_language_score" { + continue + } + scores[key] = asInt(value) + } + return scores +} + +func asMap(value interface{}) (map[string]interface{}, error) { + m, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("expected object response, got %T", value) + } + return m, nil +} + +func asSlice(value interface{}) ([]interface{}, error) { + s, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf("expected array response, got %T", value) + } + return s, nil +} + +func asString(value interface{}) string { + if value == nil { + return "" + } + switch v := value.(type) { + case string: + return v + default: + return fmt.Sprintf("%v", value) + } +} + +func asInt(value interface{}) int { + switch v := value.(type) { + case int: + return v + case int32: + return int(v) + case int64: + return int(v) + case float32: + return int(v) + case float64: + return int(v) + case string: + parsed, _ := strconv.Atoi(strings.TrimSpace(v)) + return parsed + default: + return 0 + } +} + +func asFloat(value interface{}) float64 { + switch v := value.(type) { + case float64: + return v + case float32: + return float64(v) + case int: + return float64(v) + case int32: + return float64(v) + case int64: + return float64(v) + case string: + parsed, _ := strconv.ParseFloat(strings.TrimSpace(v), 64) + return parsed + default: + return 0 + } +} + +func asFloatMap(value interface{}) map[string]float64 { + m, ok := value.(map[string]interface{}) + if !ok { + return map[string]float64{} + } + result := make(map[string]float64, len(m)) + for key, item := range m { + result[key] = asFloat(item) + } + return result +} + +func asIntMap(value interface{}) map[string]int { + m, ok := value.(map[string]interface{}) + if !ok { + return map[string]int{} + } + result := make(map[string]int, len(m)) + for key, item := range m { + result[key] = asInt(item) + } + return result +} + +func asStringSlice(value interface{}) []string { + items, ok := value.([]interface{}) + if !ok { + return nil + } + result := make([]string, 0, len(items)) + for _, item := range items { + result = append(result, strings.TrimSpace(asString(item))) + } + return result +} + +func asIntSlice(value interface{}) []int { + items, ok := value.([]interface{}) + if !ok { + return nil + } + result := make([]int, 0, len(items)) + for _, item := range items { + result = append(result, asInt(item)) + } + return result +} + +func stringAt(values []string, index int) string { + if index < len(values) { + return values[index] + } + return "" +} + +func intAt(values []int, index int) int { + if index < len(values) { + return values[index] + } + return 0 } func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index f762f5f..2e8b4b5 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -10,72 +10,64 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { +func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() - shortcut := findShortcut(t, name) + shortcut := findUserShortcut(t, name) ctx := &common.RuntimeContext{ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", Format: "json", Args: args, } return shortcut.Run(ctx) } -func findShortcut(t *testing.T, name string) *common.Shortcut { +func findUserShortcut(t *testing.T, name string) *common.Shortcut { t.Helper() - for _, s := range Shortcuts() { - if s.Name == name { - return s + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut } } t.Fatalf("shortcut %q not found", name) return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { +func writeUserJSON(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) + _ = json.NewEncoder(w).Encode(v) } -// --- me --- - func TestUserMe(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/users/me.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeUserJSON(w, map[string]interface{}{ "login": "currentuser", "name": "Current User", - "id": float64(1), + "id": 1, }) })) defer server.Close() - err := runShortcut(t, server, "me", nil) - if err != nil { + if err := runUserShortcut(t, server, "me", nil); err != nil { t.Fatalf("me failed: %v", err) } } -// --- info --- - func TestUserInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/users/alice.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeUserJSON(w, map[string]interface{}{ "login": "alice", "name": "Alice", }) })) defer server.Close() - err := runShortcut(t, server, "info", map[string]string{"login": "alice"}) - if err != nil { + if err := runUserShortcut(t, server, "info", map[string]string{"login": "alice"}); err != nil { t.Fatalf("info failed: %v", err) } } @@ -86,36 +78,241 @@ func TestUserInfoMissingLogin(t *testing.T) { })) defer server.Close() - err := runShortcut(t, server, "info", map[string]string{}) - if err == nil { + if err := runUserShortcut(t, server, "info", map[string]string{}); err == nil { t.Fatal("expected error for missing login") } } -// --- HTTP error paths --- - -func TestUserMeHTTPError(t *testing.T) { +func TestUserHeadmapShortcutBuildsYearQuery(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + if r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("year") != "2026" { + t.Fatalf("unexpected year query: %q", r.URL.Query().Get("year")) + } + writeUserJSON(w, map[string]interface{}{ + "headmaps": []interface{}{ + map[string]interface{}{"date": "2026-06-01", "contributions": 5}, + }, + "total_contributions": 5, + }) })) defer server.Close() - err := runShortcut(t, server, "me", nil) - if err == nil { - t.Fatal("expected error for HTTP 500") + if err := runUserShortcut(t, server, "headmap", map[string]string{"login": "alice", "year": "2026"}); err != nil { + t.Fatalf("headmap failed: %v", err) + } +} + +func TestUserDevelopShortcutBuildsTimeRangeQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/statistics/develop.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("start_time") != "100" || r.URL.Query().Get("end_time") != "200" { + t.Fatalf("unexpected time range query: %s", r.URL.RawQuery) + } + writeUserJSON(w, map[string]interface{}{ + "platform": map[string]interface{}{"activity": 90}, + "user": map[string]interface{}{ + "activity": 70, + "languages_percent": map[string]interface{}{"Go": 0.8}, + "each_language_score": map[string]interface{}{"Go": 88}, + }, + }) + })) + defer server.Close() + + if err := runUserShortcut(t, server, "develop", map[string]string{ + "login": "alice", + "start-time": "100", + "end-time": "200", + }); err != nil { + t.Fatalf("develop failed: %v", err) + } +} + +func TestUserTrendsFetchesAllPagesWhenFiltersPresent(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + page := r.URL.Query().Get("page") + switch page { + case "1": + writeUserJSON(w, map[string]interface{}{ + "project_trends": []interface{}{ + map[string]interface{}{ + "id": 1, + "trend_id": 11, + "trend_type": "CommitLog", + "name": "initial commit", + "action_type": "创建了代码提交(Commit)", + "user_login": "alice", + "user_name": "Alice", + "project": map[string]interface{}{ + "identifier": "repo-a", + "owner": map[string]interface{}{"login": "Gitlink"}, + }, + }, + }, + "total_count": 2, + }) + case "2": + writeUserJSON(w, map[string]interface{}{ + "project_trends": []interface{}{ + map[string]interface{}{ + "id": 2, + "trend_id": 12, + "trend_type": "PullRequest", + "name": "improve docs", + "action_type": "创建了合并请求(PR)", + "user_login": "alice", + "user_name": "Alice", + "project": map[string]interface{}{ + "identifier": "repo-b", + "owner": map[string]interface{}{"login": "Gitlink"}, + }, + }, + }, + "total_count": 2, + }) + default: + t.Fatalf("unexpected page query: %s", page) + } + })) + defer server.Close() + + if err := runUserShortcut(t, server, "trends", map[string]string{ + "login": "alice", + "page": "1", + "limit": "1", + "trend-type": "PullRequest", + }); err != nil { + t.Fatalf("trends failed: %v", err) + } + if requests != 2 { + t.Fatalf("expected 2 requests for filtered trends, got %d", requests) + } +} + +func TestBuildTimeRangeQueryRejectsInvalidOrder(t *testing.T) { + if _, _, err := buildTimeRangeQuery("200", "100"); err == nil { + t.Fatal("expected invalid time range error") + } +} + +func TestNormalizeActivityDataBuildsTimelineSummary(t *testing.T) { + result, err := normalizeActivityData("alice", map[string]interface{}{ + "dates": []interface{}{"2026.06.01", "2026.06.02"}, + "commits_count": []interface{}{3, 5}, + "issues_count": []interface{}{1, 0}, + "pull_requests_count": []interface{}{0, 2}, + }) + if err != nil { + t.Fatalf("normalizeActivityData failed: %v", err) + } + + totals := result["totals"].(map[string]interface{}) + if totals["all"].(int) != 11 { + t.Fatalf("unexpected total activity count: %#v", totals) + } + + peakDay := result["peak_day"].(map[string]interface{}) + if peakDay["date"].(string) != "2026.06.02" { + t.Fatalf("unexpected peak day: %#v", peakDay) + } +} + +func TestNormalizeDevelopDataSortsLanguages(t *testing.T) { + result, err := normalizeDevelopData("alice", map[string]interface{}{"start_time": 100}, map[string]interface{}{ + "platform": map[string]interface{}{ + "activity": 90, + }, + "user": map[string]interface{}{ + "activity": 70, + "languages_percent": map[string]interface{}{"Go": 0.4, "Python": 0.6}, + "each_language_score": map[string]interface{}{"Go": 80, "Python": 95}, + }, + }) + if err != nil { + t.Fatalf("normalizeDevelopData failed: %v", err) + } + + languages := result["languages"].([]map[string]interface{}) + if len(languages) != 2 || languages[0]["name"].(string) != "Python" { + t.Fatalf("languages not sorted by percent: %#v", languages) + } + if result["period"].(map[string]interface{})["start_time"].(int) != 100 { + t.Fatalf("unexpected period: %#v", result["period"]) + } +} + +func TestNormalizeRoleDataSortsRoleCounts(t *testing.T) { + result, err := normalizeRoleData("alice", nil, map[string]interface{}{ + "role": map[string]interface{}{ + "owner": map[string]interface{}{"count": 3, "percent": 0.6}, + "developer": map[string]interface{}{"count": 2, "percent": 0.4}, + }, + "total_projects_count": 5, + }) + if err != nil { + t.Fatalf("normalizeRoleData failed: %v", err) + } + + roles := result["roles"].([]map[string]interface{}) + if roles[0]["name"].(string) != "owner" { + t.Fatalf("expected owner to be primary role: %#v", roles) + } +} + +func TestNormalizeTrendDataAppliesFilters(t *testing.T) { + result, err := normalizeTrendData("alice", 1, 20, 2, true, trendFilters{TrendType: "PullRequest"}, []interface{}{ + map[string]interface{}{ + "id": 1, + "trend_id": 11, + "trend_type": "CommitLog", + "name": "initial commit", + "action_type": "创建了代码提交(Commit)", + "user_login": "alice", + "user_name": "Alice", + "project": map[string]interface{}{ + "identifier": "repo-a", + "owner": map[string]interface{}{"login": "Gitlink"}, + }, + }, + map[string]interface{}{ + "id": 2, + "trend_id": 12, + "trend_type": "PullRequest", + "name": "improve docs", + "action_type": "创建了合并请求(PR)", + "user_login": "alice", + "user_name": "Alice", + "project": map[string]interface{}{ + "identifier": "repo-b", + "owner": map[string]interface{}{"login": "Gitlink"}, + }, + }, + }) + if err != nil { + t.Fatalf("normalizeTrendData failed: %v", err) + } + + items := result["items"].([]map[string]interface{}) + if len(items) != 1 || items[0]["trend_type"].(string) != "PullRequest" { + t.Fatalf("unexpected filtered trend items: %#v", items) } } func TestUserInfoHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + _, _ = w.Write([]byte("server error")) })) defer server.Close() - err := runShortcut(t, server, "info", map[string]string{"login": "alice"}) - if err == nil { + if err := runUserShortcut(t, server, "info", map[string]string{"login": "alice"}); err == nil { t.Fatal("expected error for HTTP 500") } }