Merge PR #203: feat(user): 增加用户画像分析快捷命令
# Conflicts: # shortcuts/user/user.go
This commit is contained in:
commit
445f594f2a
23
README.md
23
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:
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue