diff --git a/README.md b/README.md index eb1bee0..3e7dc50 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 📖 Wiki | List, view, create, update, and delete wiki pages | | 🔍 Search | Search repositories, users | | 📊 Dataset | Query research datasets by project | -| 👤 User | View user profiles and info | +| 👤 User | View user profiles, contribution heatmaps, activity and capability statistics | | 📊 Profile | User ability, role, major, activity, and contribution statistics | | 📋 PM | Sprint management, kanban boards, weekly reports | | 🤖 Workflow | AI-powered issue triage, PR review, release notes | @@ -613,6 +613,21 @@ gitlink-cli ignore +list gitlink-cli ignore +list --name Go ``` +### User Statistics + +```bash +# User profile and current account +gitlink-cli user +me +gitlink-cli user +info --login alice + +# Contribution and activity analytics +gitlink-cli user +activity --login alice +gitlink-cli user +headmap --login alice --year 2026 +gitlink-cli user +develop --login alice --start-time 1717200000 --end-time 1719800000 +gitlink-cli user +role --login alice +gitlink-cli user +major --login alice +``` + ### Search ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index b9149ff..e760403 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -116,7 +116,7 @@ | 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | | 🔍 搜索 | 搜索仓库、用户 | | 📊 数据集 | 按项目查询科研数据集 | -| 👤 用户 | 查看用户资料和信息 | +| 👤 用户 | 查看用户资料、贡献热力图、活跃度与能力统计 | | 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 | | 📋 项目管理 | Sprint 管理、看板、周报 | | 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes | @@ -597,6 +597,21 @@ gitlink-cli ignore +list gitlink-cli ignore +list --name Go ``` +### 用户统计 + +```bash +# 用户资料与当前账户 +gitlink-cli user +me +gitlink-cli user +info --login alice + +# 贡献和活跃度分析 +gitlink-cli user +activity --login alice +gitlink-cli user +headmap --login alice --year 2026 +gitlink-cli user +develop --login alice --start-time 1717200000 --end-time 1719800000 +gitlink-cli user +role --login alice +gitlink-cli user +major --login alice +``` + ### 搜索 ```bash diff --git a/doc/changes/user-statistics-shortcuts.md b/doc/changes/user-statistics-shortcuts.md new file mode 100644 index 0000000..7ace445 --- /dev/null +++ b/doc/changes/user-statistics-shortcuts.md @@ -0,0 +1,36 @@ +# User Statistics Shortcuts + +## Background + +GitLink OpenAPI exposes user analytics endpoints, including activity, contribution heatmap, development capability, role distribution, and professional categories. gitlink-cli previously only exposed `user +me` and `user +info`. + +## What Changed + +Extended the `user` shortcut group with read-only statistics commands: + +- `user +activity` +- `user +headmap` +- `user +develop` +- `user +role` +- `user +major` + +The commands support `--login`, with fallback to `--owner` or `/users/me`. Range-based endpoints support `--start-time` and `--end-time`; heatmap supports `--year`. + +## OpenAPI Coverage + +- `GET /users/{owner}/statistics/activity` +- `GET /users/{owner}/headmaps` +- `GET /users/{owner}/statistics/develop` +- `GET /users/{owner}/statistics/role` +- `GET /users/{owner}/statistics/major` + +## Validation + +```bash +git diff --check +GOPROXY=https://goproxy.cn,direct go test ./shortcuts/user ./shortcuts +go vet ./shortcuts/user ./shortcuts +go run . user --help +GOPROXY=https://goproxy.cn,direct go test ./... +go vet ./... +``` diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index 563db88..2f8be55 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -2,6 +2,9 @@ package user import ( "fmt" + "net/url" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -39,9 +42,153 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "activity", + Description: "Show recent user activity statistics", + Flags: userLoginFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runUserStats(ctx, "statistics/activity", false) + }, + }, + { + 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: "develop", + Description: "Show user development capability statistics", + Flags: userStatisticsRangeFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runUserStats(ctx, "statistics/develop", true) + }, + }, + { + Name: "role", + Description: "Show user role distribution statistics", + Flags: userStatisticsRangeFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runUserStats(ctx, "statistics/role", true) + }, + }, + { + Name: "major", + Description: "Show user professional category statistics", + Flags: userStatisticsRangeFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runUserStats(ctx, "statistics/major", true) + }, + }, } } +func userLoginFlags() []common.Flag { + return []common.Flag{{Name: "login", Short: "l", Usage: "User login; defaults to --owner or current user"}} +} + +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 runHeadmap(ctx *common.RuntimeContext) error { + login, err := resolveUserLogin(ctx) + if err != nil { + return err + } + q := url.Values{} + if year := strings.TrimSpace(ctx.Arg("year")); year != "" { + if err := validateYear(year); err != nil { + return err + } + q.Set("year", year) + } + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/headmaps", login), q) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runUserStats(ctx *common.RuntimeContext, suffix string, withRange bool) error { + login, err := resolveUserLogin(ctx) + if err != nil { + return err + } + q := url.Values{} + if withRange { + start, hasStart, err := addTimestampQuery(q, "start_time", "start-time", ctx.Arg("start-time")) + if err != nil { + return err + } + end, hasEnd, err := addTimestampQuery(q, "end_time", "end-time", ctx.Arg("end-time")) + if err != nil { + return err + } + if hasStart && hasEnd && start > end { + return 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 err != nil { + return 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) + if err != nil { + return "", fmt.Errorf("resolve current user: %w", 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 +} + +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) + if err != nil { + return 0, false, err + } + q.Set(queryName, value) + return n, true, 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) + } + return n, 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") + } + return nil +} + func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { if len(translators) > 0 && translators[0] != nil { return translators[0] diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index 44d504f..f762f5f 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -119,3 +119,149 @@ func TestUserInfoHTTPError(t *testing.T) { t.Fatal("expected error for HTTP 500") } } + +func TestUserStatisticsHTTPError(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() + + for _, shortcut := range []string{"activity", "headmap", "develop", "role", "major"} { + t.Run(shortcut, func(t *testing.T) { + err := runShortcut(t, server, shortcut, map[string]string{"login": "alice"}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } + }) + } +} + +func TestUserActivityUsesLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/users/alice/statistics/activity.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(w, map[string]interface{}{"dates": []string{"2026.06.13"}, "issues_count": []int{1}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "activity", map[string]string{"login": "alice"}); err != nil { + t.Fatalf("activity failed: %v", err) + } +} + +func TestUserHeadmapBuildsYearQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/users/alice/headmaps.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("year"); got != "2026" { + t.Fatalf("year = %q", got) + } + writeJSON(w, map[string]interface{}{"total_contributions": 1, "headmaps": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "headmap", map[string]string{"login": "alice", "year": "2026"}); err != nil { + t.Fatalf("headmap failed: %v", err) + } +} + +func TestUserDevelopBuildsTimeRangeQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/users/alice/statistics/develop.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("start_time"); got != "1717200000" { + t.Fatalf("start_time = %q", got) + } + if got := r.URL.Query().Get("end_time"); got != "1719800000" { + t.Fatalf("end_time = %q", got) + } + writeJSON(w, map[string]interface{}{"user": map[string]interface{}{"activity": 90}}) + })) + defer server.Close() + + err := runShortcut(t, server, "develop", map[string]string{"login": "alice", "start-time": "1717200000", "end-time": "1719800000"}) + if err != nil { + t.Fatalf("develop failed: %v", err) + } +} + +func TestUserRoleAndMajorUseStatisticsEndpoints(t *testing.T) { + calls := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.URL.Path) + writeJSON(w, map[string]interface{}{"ok": true}) + })) + defer server.Close() + + if err := runShortcut(t, server, "role", map[string]string{"login": "alice"}); err != nil { + t.Fatalf("role failed: %v", err) + } + if err := runShortcut(t, server, "major", map[string]string{"login": "alice"}); err != nil { + t.Fatalf("major failed: %v", err) + } + want := []string{"/users/alice/statistics/role.json", "/users/alice/statistics/major.json"} + if len(calls) != len(want) { + t.Fatalf("calls = %#v", calls) + } + for i := range want { + if calls[i] != want[i] { + t.Fatalf("calls = %#v, want %#v", calls, want) + } + } +} + +func TestUserStatisticsResolveCurrentUser(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + if r.URL.Path != "/users/me.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, map[string]interface{}{"login": "current"}) + case 2: + if r.URL.Path != "/users/current/statistics/activity.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + writeJSON(w, map[string]interface{}{"dates": []interface{}{}}) + default: + t.Fatalf("unexpected extra request: %s", r.URL.Path) + } + })) + defer server.Close() + + shortcut := findShortcut(t, "activity") + ctx := &common.RuntimeContext{Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Format: "json", Args: map[string]string{}} + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("activity failed: %v", err) + } +} + +func TestUserStatisticsValidation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid input should not call API, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + cases := []struct { + name string + shortcut string + args map[string]string + }{ + {name: "bad year", shortcut: "headmap", args: map[string]string{"login": "alice", "year": "abcd"}}, + {name: "negative start", shortcut: "develop", args: map[string]string{"login": "alice", "start-time": "-1"}}, + {name: "start after end", shortcut: "role", args: map[string]string{"login": "alice", "start-time": "20", "end-time": "10"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runShortcut(t, server, tc.shortcut, tc.args); err == nil { + t.Fatal("expected validation error") + } + }) + } +} diff --git a/skills/gitlink-user/SKILL.md b/skills/gitlink-user/SKILL.md index 0d1aa4f..7d6e069 100644 --- a/skills/gitlink-user/SKILL.md +++ b/skills/gitlink-user/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-user -version: 1.0.0 -description: "用户操作:查看当前用户、用户详情。当用户需要查看 GitLink 用户信息时触发。" +version: 1.1.0 +description: "用户操作:查看当前用户、用户详情、贡献热力图、活跃度、开发能力、角色定位和专业定位统计。当用户需要查看 GitLink 用户信息或用户统计画像时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -22,17 +22,40 @@ metadata: |----------|------|----------| | `user +me` | 当前登录用户 | 是 | | `user +info` | 查看用户详情 | 否 | +| `user +activity` | 查看用户近期活跃度统计 | 否 | +| `user +headmap` | 查看用户贡献热力图,可按年份过滤 | 否 | +| `user +develop` | 查看用户开发能力统计 | 否 | +| `user +role` | 查看用户角色定位统计 | 否 | +| `user +major` | 查看用户专业定位 / 项目分类统计 | 否 | ## 使用示例 ```bash # 查看当前用户 -gitlink-cli user +me +gitlink-cli user +me --format json # 查看其他用户 -gitlink-cli user +info --login zhangsan +gitlink-cli user +info --login zhangsan --format json + +# 用户近期活跃度 +gitlink-cli user +activity --login zhangsan --format json + +# 用户贡献热力图 +gitlink-cli user +headmap --login zhangsan --year 2026 --format json + +# 用户开发能力、角色定位、专业定位 +gitlink-cli user +develop --login zhangsan --start-time 1717200000 --end-time 1719800000 --format json +gitlink-cli user +role --login zhangsan --format json +gitlink-cli user +major --login zhangsan --format json ``` +## 参数说明 + +- `--login` 不传时优先使用全局 `--owner`,否则通过 `/users/me` 解析当前登录用户。 +- `--start-time` / `--end-time` 为 Unix 时间戳,必须是非负整数,且 `start-time <= end-time`。 +- `--year` 必须是四位年份。 +- 新增统计命令全部是只读 `GET` 操作,适合 Agent 做开源贡献画像、科研仓库成员分析和自动报告。 + ## Raw API 补充 ```bash @@ -40,7 +63,10 @@ gitlink-cli user +info --login zhangsan gitlink-cli api GET /users/:user_id/headmaps # 用户统计 -gitlink-cli api GET /users/:user_id/statistics +gitlink-cli api GET /users/:user_id/statistics/activity +gitlink-cli api GET /users/:user_id/statistics/develop +gitlink-cli api GET /users/:user_id/statistics/role +gitlink-cli api GET /users/:user_id/statistics/major # 用户项目动态 gitlink-cli api GET /users/:user_id/project_trends