diff --git a/README.md b/README.md index 099e46e..1824270 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -487,6 +488,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: diff --git a/README.zh-CN.md b/README.zh-CN.md index 0946dae..43fb941 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -107,6 +107,7 @@ | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | | 🔍 搜索 | 搜索仓库、用户 | | 👤 用户 | 查看用户资料和信息 | +| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 | | 📋 项目管理 | Sprint 管理、看板、周报 | | 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes | @@ -465,6 +466,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 直接调用: diff --git a/doc/changes/profile-shortcuts.md b/doc/changes/profile-shortcuts.md new file mode 100644 index 0000000..4e1ae97 --- /dev/null +++ b/doc/changes/profile-shortcuts.md @@ -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` +命令组直接暴露平台原生画像数据,为"科研主体画像"等场景提供数据底座。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index c8df7cf..4c571ee 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -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: ", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 7fdc2b1..6acba14 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -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": "用户名/邮箱/手机号:", diff --git a/shortcuts/profile/profile.go b/shortcuts/profile/profile.go new file mode 100644 index 0000000..ffa0f4f --- /dev/null +++ b/shortcuts/profile/profile.go @@ -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() +} diff --git a/shortcuts/profile/profile_test.go b/shortcuts/profile/profile_test.go new file mode 100644 index 0000000..bd2a75e --- /dev/null +++ b/shortcuts/profile/profile_test.go @@ -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") + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 8f8e26e..b9d8cba 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -18,6 +18,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" @@ -41,6 +42,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), @@ -63,6 +65,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"), diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 55410f4..1f0d505 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -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", "ignore",