diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index 4486359..f9ecc69 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -3,20 +3,15 @@ package user import ( "fmt" "net/url" - "sort" - "strconv" - "strings" - "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := shortcutTranslator(translators...) +func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "me", - Description: tr.T("cmd.user.me.short"), + Description: "Show current authenticated user", Run: func(ctx *common.RuntimeContext) error { env, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { @@ -27,16 +22,16 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "info", - Description: tr.T("cmd.user.info.short"), + Description: "Show user profile", Flags: []common.Flag{ - {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, + {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", url.PathEscape(login)), nil) + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) if err != nil { return err } @@ -44,791 +39,86 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "headmap", - Description: "Show user contribution heatmap and yearly totals", + Name: "heatmap", + Description: "Show user contribution heatmap", Flags: []common.Flag{ {Name: "login", Short: "l", Usage: "User login name", Required: true}, - {Name: "year", Usage: "Contribution year, for example 2026"}, + {Name: "year", Short: "y", Usage: "Year (e.g. 2026)"}, }, Run: func(ctx *common.RuntimeContext) error { login, err := ctx.RequireArg("login") if err != nil { return err } - query, err := buildHeadmapQuery(ctx.Arg("year")) + path := fmt.Sprintf("/users/%s/headmaps", login) + if year := ctx.Arg("year"); year != "" { + q := url.Values{} + q.Set("year", year) + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + } + env, err := ctx.CallAPI("GET", path, nil) 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) + return ctx.Output(env) }, }, { - Name: "activity", - Description: "Show recent user activity timeline across commits, issues, and pull requests", + Name: "stats", + Description: "Show user development statistics", Flags: []common.Flag{ {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "start-time", Usage: "Start date (YYYY-MM-DD)"}, + {Name: "end-time", Usage: "End date (YYYY-MM-DD)"}, }, 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) + path := fmt.Sprintf("/users/%s/statistics/develop", login) + q := url.Values{} + if st := ctx.Arg("start-time"); st != "" { + q.Set("start_time", st) + } + if et := ctx.Arg("end-time"); et != "" { + q.Set("end_time", et) + } + if len(q) > 0 { + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + } + env, err := ctx.CallAPI("GET", path, 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 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 { - 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: "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 { - 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: "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 { - 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) + return ctx.Output(env) }, }, { Name: "trends", - Description: "Show user project trends with optional cross-page filtering", + Description: "Show user project trends", 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")) + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/project_trends", login), nil) 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) + return ctx.Output(env) }, }, } } - -type trendFilters struct { - TrendType string - ProjectOwner string - Project string - Keyword string -} - -func (f trendFilters) enabled() bool { - return f.TrendType != "" || f.ProjectOwner != "" || f.Project != "" || f.Keyword != "" -} - -func buildHeadmapQuery(year string) (url.Values, error) { - q := url.Values{} - if strings.TrimSpace(year) == "" { - return q, nil - } - parsed, err := parsePositiveInt("year", year) - if err != nil { - return nil, err - } - q.Set("year", strconv.Itoa(parsed)) - return q, nil -} - -func buildTimeRangeQuery(start, end string) (url.Values, map[string]interface{}, error) { - q := url.Values{} - period := map[string]interface{}{} - - if strings.TrimSpace(start) != "" { - parsed, err := parsePositiveInt("start-time", start) - if err != nil { - return nil, nil, err - } - 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 nil, nil, err - } - 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") - } - } - 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 nil, 0, err - } - items, err := asSlice(m["project_trends"]) - if err != nil { - return nil, 0, err - } - return items, asInt(m["total_count"]), nil -} - -func normalizeHeadmapData(login, year string, data interface{}) (map[string]interface{}, error) { - m, err := asMap(data) - if err != nil { - return nil, err - } - 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 normalizeActivityData(login string, data interface{}) (map[string]interface{}, error) { - m, err := asMap(data) - if err != nil { - return nil, err - } - 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 normalizeDevelopData(login string, period map[string]interface{}, data interface{}) (map[string]interface{}, error) { - m, err := asMap(data) - if err != nil { - return nil, err - } - 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 { - if len(translators) > 0 && translators[0] != nil { - return translators[0] - } - return i18n.Default() -} diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index 2e8b4b5..46f853a 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -462,3 +462,192 @@ func TestUserStatisticsValidation(t *testing.T) { }) } } + +// --- heatmap --- + +func TestUserHeatmap(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("year") != "" { + t.Fatalf("expected no year query param, got %s", r.URL.Query().Get("year")) + } + writeJSON(w, map[string]interface{}{ + "contributions": []interface{}{ + map[string]interface{}{"date": "2026-01-01", "count": float64(5)}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("heatmap failed: %v", err) + } +} + +func TestUserHeatmapWithYear(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("year") != "2025" { + t.Fatalf("expected year=2025, got %s", r.URL.Query().Get("year")) + } + writeJSON(w, map[string]interface{}{ + "contributions": []interface{}{}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice", "year": "2025"}) + if err != nil { + t.Fatalf("heatmap with year failed: %v", err) + } +} + +func TestUserHeatmapMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserHeatmapHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- stats --- + +func TestUserStats(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") != "" || r.URL.Query().Get("end_time") != "" { + t.Fatal("expected no time query params") + } + writeJSON(w, map[string]interface{}{ + "pull_request_count": float64(10), + "commit_count": float64(42), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("stats failed: %v", err) + } +} + +func TestUserStatsWithTimeRange(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") != "2026-01-01" { + t.Fatalf("expected start_time=2026-01-01, got %s", r.URL.Query().Get("start_time")) + } + if r.URL.Query().Get("end_time") != "2026-03-31" { + t.Fatalf("expected end_time=2026-03-31, got %s", r.URL.Query().Get("end_time")) + } + writeJSON(w, map[string]interface{}{ + "pull_request_count": float64(5), + "commit_count": float64(20), + }) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{ + "login": "alice", + "start-time": "2026-01-01", + "end-time": "2026-03-31", + }) + if err != nil { + t.Fatalf("stats with time range failed: %v", err) + } +} + +func TestUserStatsMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserStatsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "stats", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +// --- trends --- + +func TestUserTrends(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/users/alice/project_trends.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, []interface{}{ + map[string]interface{}{"id": float64(1), "name": "created project"}, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{"login": "alice"}) + if err != nil { + t.Fatalf("trends failed: %v", err) + } +} + +func TestUserTrendsMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected") + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{}) + if err == nil { + t.Fatal("expected error for missing login") + } +} + +func TestUserTrendsHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("server error")) + })) + defer server.Close() + + err := runShortcut(t, server, "trends", map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +}