Merge PR #203: feat(user): 增加用户画像分析快捷命令

# Conflicts:
#	shortcuts/user/user.go
This commit is contained in:
wbtiger 2026-07-14 22:49:55 +08:00
commit 445f594f2a
4 changed files with 1013 additions and 127 deletions

View File

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

View File

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

View File

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

View File

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