diff --git a/README.md b/README.md index ef57f06..da18382 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/). +The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/README.md). **[中文文档](./README.zh-CN.md)** @@ -82,7 +82,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans ## Why gitlink-cli? -- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup +- **Agent-Native Design** — Structured [Skills](./skills/README.md) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup - **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output - **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded @@ -109,6 +109,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🔍 Search | Search repositories, users | | 📊 Dataset | Query research datasets by project | | 👤 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 | @@ -468,6 +469,16 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run ``` +### Ignore File Templates + +```bash +# List all available .gitignore templates +gitlink-cli ignore +list + +# Filter templates by name +gitlink-cli ignore +list --name Go +``` + ### Search ```bash @@ -478,6 +489,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: @@ -659,7 +693,7 @@ git push gitlink The `skills/` directory contains Agent Skill files for AI-automated GitLink operations. -See [skills/README.md](skills/README.md) for details. +See [skills/README.md](./skills/README.md) for details. | Skill | Description | |-------|-------------| @@ -729,7 +763,7 @@ gitlink-cli/ ## Documentation -- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation +- [Skills Guide](./skills/README.md) — AI Agent Skills detailed documentation - [Design Document](doc/design.md) — Architecture design and development plan ## FAQ @@ -794,7 +828,7 @@ gitlink-cli uses Windows Credential Manager for secure token storage. If Credent ### Q: Where can I find the full API reference? -See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md). +See [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md). ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 396f463..359f8dd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。 +[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/README.md)。 **[English](./README.md)** @@ -82,7 +82,7 @@ ## 为什么选择 gitlink-cli? -- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink +- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/README.md),兼容 Claude Code — Agent 零配置即可操作 GitLink - **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令 - **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出 - **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装 @@ -108,6 +108,7 @@ | 🔍 搜索 | 搜索仓库、用户 | | 📊 数据集 | 按项目查询科研数据集 | | 👤 用户 | 查看用户资料和信息 | +| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 | | 📋 项目管理 | Sprint 管理、看板、周报 | | 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes | @@ -446,6 +447,16 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run ``` +### 忽略文件模板 + +```bash +# 列出所有可用的 .gitignore 模板 +gitlink-cli ignore +list + +# 按名称筛选模板 +gitlink-cli ignore +list --name Go +``` + ### 搜索 ```bash @@ -456,6 +467,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 +``` + ### 数据集 `dataset` 管理并查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。 @@ -477,7 +511,6 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --ye ``` > 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404),待平台上线后即可生效。 - ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: @@ -534,7 +567,7 @@ git push gitlink `skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。 -详见 [skills/README.md](skills/README.md) +详见 [skills/README.md](./skills/README.md) | Skill | 说明 | |-------|------| @@ -602,7 +635,7 @@ gitlink-cli/ ## 文档 -- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明 +- [Skills 使用指南](./skills/README.md) — AI Agent Skills 详细说明 - [设计文档](doc/design.md) — 架构设计和开发计划 ## 常见问题 @@ -665,7 +698,7 @@ gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Creden ### Q: 如何查看完整的 API 参考? -查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md) +查看 [skills/gitlink-shared/references/api-reference.md](./skills/gitlink-shared/references/api-reference.md) ## 许可证 diff --git a/doc/changes/ignore-shortcut.md b/doc/changes/ignore-shortcut.md new file mode 100644 index 0000000..35826a0 --- /dev/null +++ b/doc/changes/ignore-shortcut.md @@ -0,0 +1,8 @@ +# Ignore shortcut + +新增 `ignore` Shortcut 组,补齐 GitLink 忽略文件模板(`.gitignore`)查询: + +- `ignore +list` + +同时补充了单元测试、README 示例。 + 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 9bc2cbc..0739395 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -65,6 +65,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", @@ -101,6 +112,7 @@ "error.config.save_failed": "failed to save config: {message}", "error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm", "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.batch_continue_on_error": "Continue running remaining batch requests after a failure", "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", @@ -184,6 +196,10 @@ "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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index d3df9bd..2e6fc4d 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -65,6 +65,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": "列出发布", @@ -101,6 +112,7 @@ "error.config.save_failed": "保存配置失败:{message}", "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", + "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.unsupported_language": "不支持的语言:{lang}", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", @@ -184,6 +196,10 @@ "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 或标签", diff --git a/shortcuts/ignore/ignore.go b/shortcuts/ignore/ignore.go new file mode 100644 index 0000000..9f5e406 --- /dev/null +++ b/shortcuts/ignore/ignore.go @@ -0,0 +1,36 @@ +package ignore + +import ( + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns ignore-file management shortcuts. +// +// These shortcuts provide access to the GitLink ignore-file registry, +// which lists all available .gitignore templates supported by the platform. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List available ignore-file templates", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Filter ignore templates by name"}, + }, + Run: runList, + }, + } +} + +func runList(ctx *common.RuntimeContext) error { + q := url.Values{} + if name := ctx.Arg("name"); name != "" { + q.Set("name", name) + } + env, err := ctx.CallAPIWithQuery("GET", "/ignores", q) + if err != nil { + return err + } + return ctx.Output(env) +} diff --git a/shortcuts/ignore/ignore_test.go b/shortcuts/ignore/ignore_test.go new file mode 100644 index 0000000..f1ad730 --- /dev/null +++ b/shortcuts/ignore/ignore_test.go @@ -0,0 +1,124 @@ +package ignore + +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}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + 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(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func TestIgnoreList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("got method %s, want GET", r.Method) + } + if r.URL.Path != "/ignores.json" { + t.Fatalf("got path %s, want /ignores.json", r.URL.Path) + } + if got := r.URL.Query().Get("name"); got != "" { + t.Fatalf("expected no name filter, got %q", got) + } + writeJSON(t, w, map[string]interface{}{ + "ignores": []interface{}{ + map[string]interface{}{"id": 1, "name": "Go"}, + map[string]interface{}{"id": 2, "name": "Ada"}, + }, + }) + })) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{}); err != nil { + t.Fatalf("ignore list failed: %v", err) + } +} + +func TestIgnoreListWithNameFilter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("got method %s, want GET", r.Method) + } + if r.URL.Path != "/ignores.json" { + t.Fatalf("got path %s, want /ignores.json", r.URL.Path) + } + if got := r.URL.Query().Get("name"); got != "Ada" { + t.Fatalf("expected name=Ada, got %q", got) + } + writeJSON(t, w, map[string]interface{}{ + "ignores": []interface{}{ + map[string]interface{}{"id": 2, "name": "Ada"}, + }, + }) + })) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{"name": "Ada"}); err != nil { + t.Fatalf("ignore list with name filter failed: %v", err) + } +} + +func TestIgnoreListHTTPError(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() + + err := runShortcut(t, server, "list", map[string]string{}) + if err == nil { + t.Fatal("expected error for HTTP 500") + } +} + +func TestIgnoreListEmptyResult(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ignores.json" { + t.Fatalf("got path %s, want /ignores.json", r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "ignores": []interface{}{}, + }) + })) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{"name": "NONEXISTENT"}); err != nil { + t.Fatalf("ignore list with empty result failed: %v", err) + } +} 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 613f39f..00e2426 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -10,6 +10,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" "github.com/gitlink-org/gitlink-cli/shortcuts/health" + "github.com/gitlink-org/gitlink-cli/shortcuts/ignore" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/license" @@ -18,6 +19,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 +43,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), @@ -51,6 +54,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "dataset": dataset.Shortcuts(tr), "webhook": webhook.Shortcuts(tr), "health": health.Shortcuts(tr), + "ignore": ignore.Shortcuts(), "workflow": workflow.Shortcuts(), } @@ -63,6 +67,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"), @@ -73,6 +78,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "dataset": tr.T("cmd.dataset.short"), "webhook": tr.T("cmd.webhook.short"), "health": "Project health data collection", + "ignore": tr.T("cmd.ignore.short"), "workflow": "AI agent workflow analysis", } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 80fd8e8..1c28241 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -11,10 +11,10 @@ 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", - "dataset", "health", + "dataset", "health", "ignore", } groupSet := map[string]bool{}