Merge pull request 'feat(profile): 新增用户画像统计快捷命令' (#241) from luwanzhou/gitlink-cli:feat/profile-shortcuts into master
This commit is contained in:
commit
8c29d98a57
24
README.md
24
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:
|
||||
|
|
|
|||
|
|
@ -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 直接调用:
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
命令组直接暴露平台原生画像数据,为"科研主体画像"等场景提供数据底座。
|
||||
|
|
@ -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: ",
|
||||
|
|
|
|||
|
|
@ -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": "用户名/邮箱/手机号:",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue