feat(profile): 新增用户画像统计快捷命令

新增只读 profile 命令组,封装此前无 shortcut 覆盖的
GitLink 用户画像统计接口:

profile +ability      -> /users/{user}/statistics/develop
profile +role         -> /users/{user}/statistics/role
profile +major        -> /users/{user}/statistics/major
profile +activity     -> /users/{user}/statistics/activity
profile +contribution -> /users/{user}/headmaps

省略 --user 时默认通过 /users/me 解析当前认证用户。
统计命令支持 --start-time/--end-time 时间窗口,
+contribution 支持 --year 参数。

包含单元测试、中英双语 i18n 帮助文本、README 更新
及变更说明文档。
This commit is contained in:
luwanzhou 2026-06-13 22:58:19 +08:00
parent 52b7093846
commit da4c8c0d10
9 changed files with 490 additions and 17 deletions

View File

@ -108,6 +108,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 🔍 Search | Search repositories, users |
| 👤 User | View user profiles and info |
| 📊 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 |
@ -477,6 +478,29 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### User Profile
`profile` surfaces GitLink's native user statistics (ability, role, major, activity,
contribution). When `--user` is omitted it defaults to the authenticated user.
```bash
# Development ability scores + language breakdown
gitlink-cli profile +ability --user zhangsan
# Role positioning / major (discipline) categories
gitlink-cli profile +role --user zhangsan
gitlink-cli profile +major --user zhangsan
# Ability within a time window (Unix timestamps)
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
# Recent activity (issues / PRs / commits per day) for the current user
gitlink-cli profile +activity
# Contribution heatmap for a given year
gitlink-cli profile +contribution --user zhangsan --year 2025
```
### Workflow Agent Commands
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:

View File

@ -107,6 +107,7 @@
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 🔍 搜索 | 搜索仓库、用户 |
| 👤 用户 | 查看用户资料和信息 |
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
@ -455,6 +456,29 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### 用户画像
`profile` 暴露 GitLink 原生的用户画像统计(开发能力、角色定位、专业定位、近期活动、贡献热力图)。
省略 `--user` 时默认使用当前认证用户。
```bash
# 开发能力评分 + 语言分布
gitlink-cli profile +ability --user zhangsan
# 角色定位 / 专业(学科)定位
gitlink-cli profile +role --user zhangsan
gitlink-cli profile +major --user zhangsan
# 指定时间范围的开发能力Unix 时间戳)
gitlink-cli profile +ability --user zhangsan --start-time 1704067200 --end-time 1735689600
# 当前用户的近期活动(每日 疑修 / 合并请求 / 提交)
gitlink-cli profile +activity
# 指定年份的贡献热力图
gitlink-cli profile +contribution --user zhangsan --year 2025
```
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:

View File

@ -0,0 +1,56 @@
# Profile (User Statistics) Shortcuts
## Summary
Adds a new read-only `profile` shortcut group that wraps GitLink's user statistics
APIs (development ability, role positioning, major/discipline, recent activity, and
contribution heatmap). These endpoints previously had no shortcut coverage, forcing
agents to fall back to Raw API calls — the `gitlink-contributor-insight` skill even
documents `user +stats`/`user +heatmap` as unavailable. The `profile` group surfaces
the platform's native portrait data directly, powering "research subject portrait"
scenarios.
## Commands
| Command | Purpose | Endpoint |
|---------|---------|----------|
| `gitlink-cli profile +ability` | Development ability scores + language breakdown | `GET /users/{user}/statistics/develop` |
| `gitlink-cli profile +role` | Role positioning | `GET /users/{user}/statistics/role` |
| `gitlink-cli profile +major` | Major/discipline categories | `GET /users/{user}/statistics/major` |
| `gitlink-cli profile +activity` | Recent activity (issues/PRs/commits per day) | `GET /users/{user}/statistics/activity` |
| `gitlink-cli profile +contribution` | Contribution heatmap | `GET /users/{user}/headmaps` |
## Behaviour
- `--user`/`-u` selects the target user. When omitted, the user is resolved from the
authenticated account via `/users/me`, so `gitlink-cli profile +ability` works with
no arguments.
- `+ability`, `+role`, and `+major` accept optional `--start-time` / `--end-time`
(Unix timestamps) that map to the `start_time` / `end_time` query parameters.
- `+contribution` accepts an optional `--year` query parameter.
## Tests
Unit tests cover endpoint paths for every subcommand, the `start_time`/`end_time` and
`year` query parameter mapping, current-user fallback via `/users/me`, the missing-login
error path, and HTTP error handling.
## 中文说明
### 变更内容
- 新增 `profile` 命令组,封装 GitLink 用户画像统计接口:
- `profile +ability` 开发能力评分(影响力/贡献度/活跃度/项目经验/语言能力)及语言分布
- `profile +role` 角色定位
- `profile +major` 专业/学科定位(如深度学习、量子计算)
- `profile +activity` 近期活动统计(每日疑修/合并请求/提交数量)
- `profile +contribution` 贡献热力图
- `--user`/`-u` 指定目标用户;缺省时通过 `/users/me` 解析为当前认证用户。
- `+ability`/`+role`/`+major` 支持 `--start-time`/`--end-time`Unix 时间戳)。
- `+contribution` 支持 `--year`
### 价值
这些接口此前无任何 shortcut 封装,`gitlink-contributor-insight` Skill 甚至将
`user +stats`/`user +heatmap` 标注为"不可用"并改用 PR 时间戳手工推算。`profile`
命令组直接暴露平台原生画像数据,为"科研主体画像"等场景提供数据底座。

View File

@ -54,6 +54,17 @@
"cmd.pr.version_diff.short": "Show diff for a pull request patchset version",
"cmd.pr.versions.short": "List pull request patchset versions",
"cmd.pr.view.short": "View pull request details",
"cmd.profile.ability.long": "Show a user's development ability scores (influence, contribution, activity, experience, language) and language breakdown, sourced from the GitLink platform statistics API.",
"cmd.profile.ability.short": "Show a user's development ability scores",
"cmd.profile.activity.long": "Show a user's recent activity statistics: daily issues, pull requests, and commits over a time window.",
"cmd.profile.activity.short": "Show a user's recent activity statistics",
"cmd.profile.contribution.long": "Show a user's contribution heatmap (daily contribution counts) for a given year.",
"cmd.profile.contribution.short": "Show a user's contribution heatmap",
"cmd.profile.major.long": "Show a user's major/discipline categories (e.g. deep learning, quantum computing) inferred from their projects.",
"cmd.profile.major.short": "Show a user's major/discipline categories",
"cmd.profile.role.long": "Show a user's role positioning derived from the GitLink platform statistics API.",
"cmd.profile.role.short": "Show a user's role positioning",
"cmd.profile.short": "User profile and statistics operations",
"cmd.release.create.short": "Create a release",
"cmd.release.delete.short": "Delete a release",
"cmd.release.list.short": "List releases",
@ -89,14 +100,15 @@
"error.auth.token_empty": "token cannot be empty",
"error.config.save_failed": "failed to save config: {message}",
"error.missing_required_flag": "required flag --{name} is missing",
"error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login",
"error.unsupported_language": "unsupported language: {lang}",
"flag.api.body": "Request body (JSON string)",
"flag.api.body_file": "Read request body JSON from a file",
"flag.api.body_stdin": "Read request body JSON from stdin",
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
"flag.api.batch_dry_run": "Preview batch requests without sending remote requests",
"flag.api.batch_file": "Read an API batch plan from a JSON file",
"flag.api.batch_var": "Override a batch template variable (key=value, repeatable)",
"flag.api.body": "Request body (JSON string)",
"flag.api.body_file": "Read request body JSON from a file",
"flag.api.body_stdin": "Read request body JSON from stdin",
"flag.api.header": "Additional headers (key:value)",
"flag.api.query": "Query parameters (key=val&key2=val2)",
"flag.auth.token": "Login by pasting an existing token",
@ -113,6 +125,8 @@
"flag.format": "Output format: json, table, yaml (default: table)",
"flag.issue.add_label": "Label to add to each matching issue",
"flag.issue.assignee": "Assignee login",
"flag.issue.assignee_id": "Assignee user ID",
"flag.issue.author_id": "Author user ID",
"flag.issue.batch.reason": "Optional reason shown in the batch result",
"flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.",
"flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7",
@ -120,8 +134,6 @@
"flag.issue.batch_label.state": "Filter by issue state",
"flag.issue.batch_list.limit": "Maximum issues to return, capped at 100",
"flag.issue.batch_process.limit": "Maximum issues to process, capped at 100",
"flag.issue.assignee_id": "Assignee user ID",
"flag.issue.author_id": "Author user ID",
"flag.issue.body": "Issue description",
"flag.issue.label": "Label ID",
"flag.issue.label_filter": "Filter by existing label",
@ -149,18 +161,22 @@
"flag.pr.file": "Filter diff by file path",
"flag.pr.head": "Source branch",
"flag.pr.id": "PR number",
"flag.pr.milestone_id": "Milestone ID",
"flag.pr.merge_method": "Merge method: merge, rebase, squash",
"flag.pr.milestone_id": "Milestone ID",
"flag.pr.priority_id": "Priority ID",
"flag.pr.review_commit": "Commit SHA to attach the review to",
"flag.pr.review_content": "Review content",
"flag.pr.reviewer_id": "Reviewer user ID",
"flag.pr.review_status": "Review status: common, approved, rejected",
"flag.pr.review_status_filter": "Filter review status: common, approved, rejected",
"flag.pr.reviewer_id": "Reviewer user ID",
"flag.pr.state": "Filter: open, merged, closed",
"flag.pr.tag_id": "Issue tag ID",
"flag.pr.title": "PR title",
"flag.pr.version_id": "Patchset version ID",
"flag.profile.end_time": "End time (Unix timestamp)",
"flag.profile.start_time": "Start time (Unix timestamp)",
"flag.profile.user": "Target user login (defaults to the authenticated user)",
"flag.profile.year": "Year for the contribution heatmap (e.g. 2025)",
"flag.release.body": "Release notes",
"flag.release.id": "Release ID",
"flag.release.id_or_tag": "Release ID or tag",
@ -194,7 +210,6 @@
"output.auth.login_hint": " Run: gitlink-cli auth login",
"output.config.file": "Config file: {path}",
"output.config.not_set": "(not set)",
"output.version": "gitlink-cli {version}",
"output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.",
"output.doctor.api_auth.failed": "Authenticated API request failed: {message}",
"output.doctor.api_auth.no_login": "Authenticated API response did not include a login field.",
@ -217,6 +232,7 @@
"output.doctor.suggestion.check_token": "Check whether the stored token is valid, or run gitlink-cli auth login again.",
"output.doctor.suggestion.fix_config_yaml": "Fix the YAML syntax in the gitlink-cli config file.",
"output.doctor.suggestion.pass_owner_repo": "Run the command with --owner and --repo when not inside a GitLink repository.",
"output.version": "gitlink-cli {version}",
"prompt.auth.password": "Password: ",
"prompt.auth.token": "Paste your access token: ",
"prompt.auth.username": "Username/Email/Phone: ",

View File

@ -54,6 +54,17 @@
"cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff",
"cmd.pr.versions.short": "列出拉取请求补丁集版本",
"cmd.pr.view.short": "查看拉取请求详情",
"cmd.profile.ability.long": "显示用户的开发能力评分(影响力、贡献度、活跃度、项目经验、语言能力)及语言分布,数据来自 GitLink 平台统计接口。",
"cmd.profile.ability.short": "显示用户开发能力评分",
"cmd.profile.activity.long": "显示用户近期活动统计:时间范围内每日的疑修、合并请求和提交数量。",
"cmd.profile.activity.short": "显示用户近期活动统计",
"cmd.profile.contribution.long": "显示用户在指定年份的贡献热力图(每日贡献次数)。",
"cmd.profile.contribution.short": "显示用户贡献热力图",
"cmd.profile.major.long": "显示根据用户项目推断的专业/学科领域(如深度学习、量子计算)。",
"cmd.profile.major.short": "显示用户专业/学科定位",
"cmd.profile.role.long": "显示来自 GitLink 平台统计接口的用户角色定位。",
"cmd.profile.role.short": "显示用户角色定位",
"cmd.profile.short": "用户画像与统计操作",
"cmd.release.create.short": "创建发布",
"cmd.release.delete.short": "删除发布",
"cmd.release.list.short": "列出发布",
@ -89,14 +100,15 @@
"error.auth.token_empty": "Token 不能为空",
"error.config.save_failed": "保存配置失败:{message}",
"error.missing_required_flag": "缺少必需参数 --{name}",
"error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录",
"error.unsupported_language": "不支持的语言:{lang}",
"flag.api.body": "请求体JSON 字符串)",
"flag.api.body_file": "从文件读取 JSON 请求体",
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
"flag.api.batch_dry_run": "预览批处理请求,不发送远端请求",
"flag.api.batch_file": "从 JSON 文件读取 API 批处理计划",
"flag.api.batch_var": "覆盖批处理模板变量key=value可重复",
"flag.api.body": "请求体JSON 字符串)",
"flag.api.body_file": "从文件读取 JSON 请求体",
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
"flag.api.header": "附加请求头key:value",
"flag.api.query": "查询参数key=val&key2=val2",
"flag.auth.token": "通过粘贴已有 Token 登录",
@ -113,6 +125,8 @@
"flag.format": "输出格式json、table、yaml默认table",
"flag.issue.add_label": "要添加到每个匹配议题的标签",
"flag.issue.assignee": "负责人登录名",
"flag.issue.assignee_id": "负责人用户 ID",
"flag.issue.author_id": "作者用户 ID",
"flag.issue.batch.reason": "批量结果中显示的可选原因",
"flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。",
"flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7",
@ -120,8 +134,6 @@
"flag.issue.batch_label.state": "按议题状态筛选",
"flag.issue.batch_list.limit": "最多返回的议题数,上限 100",
"flag.issue.batch_process.limit": "最多处理的议题数,上限 100",
"flag.issue.assignee_id": "负责人用户 ID",
"flag.issue.author_id": "作者用户 ID",
"flag.issue.body": "议题描述",
"flag.issue.label": "标签 ID",
"flag.issue.label_filter": "按已有标签筛选",
@ -149,18 +161,22 @@
"flag.pr.file": "按文件路径筛选 diff",
"flag.pr.head": "源分支",
"flag.pr.id": "PR 编号",
"flag.pr.milestone_id": "里程碑 ID",
"flag.pr.merge_method": "合并方式merge、rebase、squash",
"flag.pr.milestone_id": "里程碑 ID",
"flag.pr.priority_id": "优先级 ID",
"flag.pr.review_commit": "关联评审的 Commit SHA",
"flag.pr.review_content": "评审内容",
"flag.pr.reviewer_id": "评审人用户 ID",
"flag.pr.review_status": "评审状态common、approved、rejected",
"flag.pr.review_status_filter": "按评审状态筛选common、approved、rejected",
"flag.pr.reviewer_id": "评审人用户 ID",
"flag.pr.state": "筛选open、merged、closed",
"flag.pr.tag_id": "议题标签 ID",
"flag.pr.title": "PR 标题",
"flag.pr.version_id": "补丁集版本 ID",
"flag.profile.end_time": "结束时间Unix 时间戳)",
"flag.profile.start_time": "开始时间Unix 时间戳)",
"flag.profile.user": "目标用户登录名(默认为当前认证用户)",
"flag.profile.year": "贡献热力图的年份(如 2025",
"flag.release.body": "发布说明",
"flag.release.id": "发布 ID",
"flag.release.id_or_tag": "发布 ID 或标签",
@ -194,7 +210,6 @@
"output.auth.login_hint": " 运行gitlink-cli auth login",
"output.config.file": "配置文件:{path}",
"output.config.not_set": "(未设置)",
"output.version": "gitlink-cli {version}",
"output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。",
"output.doctor.api_auth.failed": "认证 API 请求失败:{message}",
"output.doctor.api_auth.no_login": "认证 API 响应中缺少 login 字段。",
@ -217,6 +232,7 @@
"output.doctor.suggestion.check_token": "检查已保存的 Token 是否有效,或重新运行 gitlink-cli auth login。",
"output.doctor.suggestion.fix_config_yaml": "修复 gitlink-cli 配置文件中的 YAML 语法。",
"output.doctor.suggestion.pass_owner_repo": "不在 GitLink 仓库目录内时,请通过 --owner 和 --repo 指定仓库。",
"output.version": "gitlink-cli {version}",
"prompt.auth.password": "密码:",
"prompt.auth.token": "粘贴你的访问 Token",
"prompt.auth.username": "用户名/邮箱/手机号:",

View File

@ -0,0 +1,149 @@
// Package profile implements user profile shortcuts that surface GitLink's
// platform statistics (develop ability, role, major, activity, contribution).
// These power "research subject portrait" scenarios for the gitlink-cli skills.
package profile
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns user profile (research subject portrait) shortcuts.
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
userFlag := common.Flag{Name: "user", Short: "u", Usage: tr.T("flag.profile.user")}
timeFlags := []common.Flag{
{Name: "start-time", Usage: tr.T("flag.profile.start_time")},
{Name: "end-time", Usage: tr.T("flag.profile.end_time")},
}
statFlags := append([]common.Flag{userFlag}, timeFlags...)
return []*common.Shortcut{
{
Name: "ability",
Description: tr.T("cmd.profile.ability.short"),
Long: tr.T("cmd.profile.ability.long"),
Flags: statFlags,
Run: statRun("develop"),
},
{
Name: "role",
Description: tr.T("cmd.profile.role.short"),
Long: tr.T("cmd.profile.role.long"),
Flags: statFlags,
Run: statRun("role"),
},
{
Name: "major",
Description: tr.T("cmd.profile.major.short"),
Long: tr.T("cmd.profile.major.long"),
Flags: statFlags,
Run: statRun("major"),
},
{
Name: "activity",
Description: tr.T("cmd.profile.activity.short"),
Long: tr.T("cmd.profile.activity.long"),
Flags: []common.Flag{userFlag},
Run: func(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/statistics/activity", user), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "contribution",
Description: tr.T("cmd.profile.contribution.short"),
Long: tr.T("cmd.profile.contribution.long"),
Flags: []common.Flag{
userFlag,
{Name: "year", Usage: tr.T("flag.profile.year")},
},
Run: func(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
q := url.Values{}
if v := ctx.Arg("year"); v != "" {
q.Set("year", v)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/headmaps", user), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
// statRun builds a Run that calls /users/{user}/statistics/{kind} with an
// optional start_time/end_time window (Unix timestamps).
func statRun(kind string) func(ctx *common.RuntimeContext) error {
return func(ctx *common.RuntimeContext) error {
user, err := resolveUser(ctx)
if err != nil {
return err
}
q := url.Values{}
if v := ctx.Arg("start-time"); v != "" {
q.Set("start_time", v)
}
if v := ctx.Arg("end-time"); v != "" {
q.Set("end_time", v)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/users/%s/statistics/%s", user, kind), q)
if err != nil {
return err
}
return ctx.Output(env)
}
}
// resolveUser returns the target user identifier from the --user flag, falling
// back to the currently authenticated user (/users/me) when the flag is omitted.
func resolveUser(ctx *common.RuntimeContext) (string, error) {
if v := ctx.Arg("user"); v != "" {
return v, nil
}
env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return "", err
}
if login := extractLogin(env); login != "" {
return login, nil
}
return "", fmt.Errorf("%s", ctx.Tr.T("error.profile.user_required"))
}
// extractLogin pulls the "login" identifier out of a /users/me envelope.
func extractLogin(env *output.Envelope) string {
data, ok := env.Data.(map[string]interface{})
if !ok {
return ""
}
if v, ok := data["login"].(string); ok {
return v
}
return ""
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}

View File

@ -0,0 +1,185 @@
package profile
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// --- ability / role / major share statRun ---
func TestProfileAbilityExplicitUser(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)
}
writeJSON(w, map[string]interface{}{
"user": map[string]interface{}{"contribution": float64(78)},
})
}))
defer server.Close()
if err := runShortcut(t, server, "ability", map[string]string{"user": "alice"}); err != nil {
t.Fatalf("ability failed: %v", err)
}
}
func TestProfileAbilityTimeWindow(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("start_time"); got != "100" {
t.Fatalf("start_time = %q, want 100", got)
}
if got := r.URL.Query().Get("end_time"); got != "200" {
t.Fatalf("end_time = %q, want 200", got)
}
writeJSON(w, map[string]interface{}{"user": map[string]interface{}{}})
}))
defer server.Close()
args := map[string]string{"user": "alice", "start-time": "100", "end-time": "200"}
if err := runShortcut(t, server, "ability", args); err != nil {
t.Fatalf("ability with window failed: %v", err)
}
}
func TestProfileMajor(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/bob/statistics/major.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"categories": []string{"深度学习"}})
}))
defer server.Close()
if err := runShortcut(t, server, "major", map[string]string{"user": "bob"}); err != nil {
t.Fatalf("major failed: %v", err)
}
}
func TestProfileRole(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/bob/statistics/role.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{})
}))
defer server.Close()
if err := runShortcut(t, server, "role", map[string]string{"user": "bob"}); err != nil {
t.Fatalf("role failed: %v", err)
}
}
// --- activity ---
func TestProfileActivity(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/bob/statistics/activity.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"dates": []string{}})
}))
defer server.Close()
if err := runShortcut(t, server, "activity", map[string]string{"user": "bob"}); err != nil {
t.Fatalf("activity failed: %v", err)
}
}
// --- contribution ---
func TestProfileContributionWithYear(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/bob/headmaps.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if got := r.URL.Query().Get("year"); got != "2025" {
t.Fatalf("year = %q, want 2025", got)
}
writeJSON(w, map[string]interface{}{"total_contributions": float64(12)})
}))
defer server.Close()
if err := runShortcut(t, server, "contribution", map[string]string{"user": "bob", "year": "2025"}); err != nil {
t.Fatalf("contribution failed: %v", err)
}
}
// --- resolveUser falls back to /users/me ---
func TestProfileDefaultsToCurrentUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/users/me.json":
writeJSON(w, map[string]interface{}{"login": "currentuser"})
case "/users/currentuser/statistics/develop.json":
writeJSON(w, map[string]interface{}{"user": map[string]interface{}{}})
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
if err := runShortcut(t, server, "ability", nil); err != nil {
t.Fatalf("ability (default user) failed: %v", err)
}
}
func TestProfileDefaultUserMissingLogin(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{}{"name": "no login here"})
}))
defer server.Close()
if err := runShortcut(t, server, "ability", nil); err == nil {
t.Fatal("expected error when /users/me has no login")
}
}
// --- HTTP error path ---
func TestProfileHTTPError(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()
if err := runShortcut(t, server, "major", map[string]string{"user": "bob"}); err == nil {
t.Fatal("expected error for HTTP 500")
}
}

View File

@ -17,6 +17,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/profile"
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
@ -40,6 +41,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"profile": profile.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
@ -61,6 +63,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"profile": tr.T("cmd.profile.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),

View File

@ -11,7 +11,7 @@ func TestRegisterAll(t *testing.T) {
RegisterAll(root)
expectedGroups := []string{
"repo", "issue", "label", "license", "pr", "release", "branch",
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"health",