feat(user): add statistics shortcuts #238
17
README.md
17
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 |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ./...
|
||||
```
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue