feat(user): add statistics shortcuts #238

Merged
wbtiger merged 1 commits from wangyue111/gitlink-cli:feat/user-statistics-shortcuts into master 2026-07-14 22:27:16 +08:00
6 changed files with 392 additions and 7 deletions

View File

@ -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 |
@ -518,6 +518,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

View File

@ -116,7 +116,7 @@
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
| 👤 用户 | 查看用户资料和信息 |
| 👤 用户 | 查看用户资料、贡献热力图、活跃度与能力统计 |
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
@ -496,6 +496,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

View File

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

View File

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

View File

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

View File

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