Merge PR #73: feat(user): add SSH key shortcuts

# Conflicts:
#	README.zh-CN.md
#	shortcuts/user/user.go
#	shortcuts/user/user_test.go
This commit is contained in:
wbtiger 2026-07-14 22:50:03 +08:00
commit 88a0883a61
9 changed files with 522 additions and 657 deletions

View File

@ -530,6 +530,24 @@ gitlink-cli profile +activity
gitlink-cli profile +contribution --user zhangsan --year 2025
```
### User Account
```bash
# Show current authenticated user
gitlink-cli user +me
# List SSH public keys
gitlink-cli user +keys
# Add an SSH public key from inline content or a file
gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..."
gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub
gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub
# Delete an SSH public key
gitlink-cli user +delete-key --id 123
```
### Workflow Agent Commands
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
@ -726,7 +744,7 @@ See [skills/README.md](./skills/README.md) for details.
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) |
| `gitlink-search` | Search (repositories, users, etc.) |
| `gitlink-org` | Organization management (members, teams, etc.) |
| `gitlink-user` | User management (profile info, etc.) |
| `gitlink-user` | User management (profile info, SSH keys, etc.) |
| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) |
| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) |
| `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) |

View File

@ -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)**
@ -78,11 +78,19 @@
<a href="https://www.gitlink.org.cn/jiangtx" title="jiangtx"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/J/67_157_94/120.png" width="40" height="40" alt="jiangtx" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/jiangtx">jiangtx</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/luwanzhou" title="luwanzhou"><img src="https://www.gitlink.org.cn/system/lets/letter_avatars/2/L/165_135_246/120.png" width="40" height="40" alt="luwanzhou" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/luwanzhou">luwanzhou</a></sub>
</div>
<div align="center">
<a href="https://www.gitlink.org.cn/whale_hihihi" title="whale_hihihi"><img src="https://www.gitlink.org.cn/images/avatars/User/137722?t=1778575729" width="40" height="40" alt="whale_hihihi" style="border-radius: 50%;"></a>
<br><sub><a href="https://www.gitlink.org.cn/whale_hihihi">whale_hihihi</a></sub>
</div>
</div>
## 为什么选择 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` 一条命令安装
@ -96,17 +104,20 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息、洞察数据和互动状态 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🐛 Issue | 创建、更新、关闭、批量关闭/更新/删除、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
| 🌿 分支 | 列出、创建、删除、恢复、设置默认分支、保护分支 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release |
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 |
| 🔍 搜索 | 搜索仓库、用户 |
| 📊 数据集 | 按项目查询科研数据集 |
| 👤 用户 | 查看用户资料和信息 |
| 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes |
@ -274,6 +285,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Wiki 管理
```bash
# 列出 Wiki 页面(目录结构)
gitlink-cli wiki +list --owner Gitlink --repo forgeplus --project-id 12345
# 查看 Wiki 页面
gitlink-cli wiki +view --owner Gitlink --repo forgeplus --project-id 12345 -n home
# 创建 Wiki 页面
gitlink-cli wiki +create --owner Gitlink --repo forgeplus --project-id 12345 \
-n getting-started -t "快速开始" -c "# 快速开始指南"
# 更新 Wiki 页面标题和/或内容
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -c "# 更新后的内容"
gitlink-cli wiki +update --owner Gitlink --repo forgeplus --project-id 12345 -n home -t "新标题" -c "新内容"
# 删除 Wiki 页面
gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --project-id 12345 -n old-page
```
### 成员管理
```bash
@ -323,6 +356,14 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
# 从 CSV 文件批量关闭 Issue
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
# 按 API issue id 预览批量更新元数据
# 注意:--ids 是 API issue id不是网页 URL 中的 Issue 编号。
gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 --status-id 3 --priority-id 2 --dry-run
# 危险批量删除必须先 dry-run真实执行还要显式 --yes
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run
gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes
# 添加评论
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
@ -403,34 +444,6 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
```
### 分支管理
```bash
# 列出分支,支持关键字和已删除分支过滤
gitlink-cli branch +list --owner Gitlink --repo forgeplus --keyword feature
gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted
# 列出全部分支(无分页)
gitlink-cli branch +all --owner Gitlink --repo forgeplus
# 创建分支,先 dry-run 预览
gitlink-cli branch +create --owner Gitlink --repo forgeplus --name feature/new-feature --from master --dry-run
# 删除分支,先 dry-run 预览
gitlink-cli branch +delete --owner Gitlink --repo forgeplus --name feature/old-feature --dry-run
# 设置默认分支
gitlink-cli branch +set-default --owner Gitlink --repo forgeplus --name develop --dry-run
# 恢复已删除分支
gitlink-cli branch +restore --owner Gitlink --repo forgeplus --branch-id 7 --name feature/old-feature --dry-run
# 保护/取消保护分支
gitlink-cli branch +protect --owner Gitlink --repo forgeplus --name main
gitlink-cli branch +unprotect --owner Gitlink --repo forgeplus --name main
```
### 发布管理
```bash
@ -473,6 +486,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
@ -483,17 +506,67 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### 反馈建议
### 用户画像
`profile` 暴露 GitLink 原生的用户画像统计(开发能力、角色定位、专业定位、近期活动、贡献热力图)。
省略 `--user` 时默认使用当前认证用户。
```bash
# 预览反馈提交,不调用 API
gitlink-cli feedback +create --content "CLI 安装文档需要补充 Windows 说明。" --category docs --dry-run
# 开发能力评分 + 语言分布
gitlink-cli profile +ability --user zhangsan
# 从文件提交较长反馈,并附带联系方式和相关仓库
gitlink-cli feedback +create --from feedback.md --category cli --contact mengz@example.com --repo-ref Gitlink/gitlink-cli
# 角色定位 / 专业(学科)定位
gitlink-cli profile +role --user zhangsan
gitlink-cli profile +major --user zhangsan
# 从管道读取反馈内容
Get-Content feedback.md | gitlink-cli feedback +create --stdin --category feature
# 指定时间范围的开发能力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 科研数据集(标题、描述、论文内容、许可证、所属项目)。
```bash
# 按数字项目 ID 列出一个或多个项目的数据集
gitlink-cli dataset +list --ids 5988
# 查看仓库的数据集及其附件
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
# 创建 / 更新仓库数据集(先用 --dry-run 预览)
gitlink-cli dataset +create --owner me --repo proj -t "我的数据集" -d "..." --license-id 359 --dry-run
gitlink-cli dataset +update --owner me --repo proj -t "我的数据集" -d "更新"
# 删除数据集附件(破坏性:先预览,再用 --yes 确认)
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --dry-run
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
```
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404待平台上线后即可生效。
### 用户账号
```bash
# 查看当前登录用户
gitlink-cli user +me
# 列出 SSH 公钥
gitlink-cli user +keys
# 通过内容或文件添加 SSH 公钥
gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..."
gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub
gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub
# 删除 SSH 公钥
gitlink-cli user +delete-key --id 123
```
### Raw API
@ -552,13 +625,13 @@ git push gitlink
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
详见 [skills/README.md](skills/README.md)
详见 [skills/README.md](./skills/README.md)
| Skill | 说明 |
|-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作创建、查看、删除、Fork、洞察数据等 |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
@ -566,7 +639,7 @@ git push gitlink
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
| `gitlink-search` | 搜索功能(仓库、用户等) |
| `gitlink-user` | 用户管理(个人信息等) |
| `gitlink-user` | 用户管理(个人信息、SSH 公钥等) |
| `gitlink-pm` | 项目管理Sprint、看板、周报等 |
| `gitlink-workflow` | AI 自动化工作流Issue 分类、PR Review、Release Notes 等) |
@ -620,7 +693,7 @@ gitlink-cli/
## 文档
- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明
- [Skills 使用指南](./skills/README.md) — AI Agent Skills 详细说明
- [设计文档](doc/design.md) — 架构设计和开发计划
## 常见问题
@ -683,7 +756,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)
## 许可证

View File

@ -0,0 +1,28 @@
# User SSH Key Shortcuts
## Summary
Adds account-level SSH public key management shortcuts under the existing `user` command group.
## Commands
```bash
gitlink-cli user +keys
gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..."
gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub
gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub
gitlink-cli user +delete-key --id 123
```
## Behavior
- `user +keys` calls `GET /public_keys` with `--page` and `--limit`.
- `user +add-key` calls `POST /public_keys` with `title` and `key`.
- `user +delete-key` calls `DELETE /public_keys/{id}`.
- `user +add-key` accepts either inline key content or a public key file path.
- `user +add-key --from` can infer the default title from the public key filename.
- Key content and key IDs are validated before a write/delete request is sent.
## Tests
- Unit tests cover list pagination, inline key creation, file-based key creation, invalid key sources, delete path construction, and invalid key IDs.

View File

@ -104,7 +104,10 @@
"cmd.search.repos.short": "Search repositories",
"cmd.search.short": "Search operations",
"cmd.search.users.short": "Search users",
"cmd.user.add_key.short": "Add an SSH public key to the current user",
"cmd.user.delete_key.short": "Delete one of the current user's SSH public keys",
"cmd.user.info.short": "Show user profile",
"cmd.user.keys.short": "List current user's SSH public keys",
"cmd.user.me.short": "Show current authenticated user",
"cmd.user.short": "User operations",
"cmd.version.short": "Print version information",
@ -235,6 +238,10 @@
"flag.sort_by": "Sort field",
"flag.sort_direction": "Sort direction: asc, desc",
"flag.user": "User login (default: current user)",
"flag.user.key.content": "SSH public key content",
"flag.user.key.from": "Read SSH public key content from a file",
"flag.user.key.id": "SSH key ID",
"flag.user.key.title": "SSH key title. Defaults to the filename when --from is used.",
"flag.user.login": "User login name",
"flag.webhook.active": "Whether the webhook is active: true or false",
"flag.webhook.branch_filter": "Branch glob filter for push/create/delete events",

View File

@ -104,7 +104,10 @@
"cmd.search.repos.short": "搜索仓库",
"cmd.search.short": "搜索操作",
"cmd.search.users.short": "搜索用户",
"cmd.user.add_key.short": "为当前用户添加 SSH 公钥",
"cmd.user.delete_key.short": "删除当前用户的 SSH 公钥",
"cmd.user.info.short": "显示用户资料",
"cmd.user.keys.short": "列出当前用户的 SSH 公钥",
"cmd.user.me.short": "显示当前认证用户",
"cmd.user.short": "用户操作",
"cmd.version.short": "打印版本信息",
@ -235,6 +238,10 @@
"flag.sort_by": "排序字段",
"flag.sort_direction": "排序方向asc、desc",
"flag.user": "用户登录名(默认:当前用户)",
"flag.user.key.content": "SSH 公钥内容",
"flag.user.key.from": "从文件读取 SSH 公钥内容",
"flag.user.key.id": "SSH 公钥 ID",
"flag.user.key.title": "SSH 公钥标题。使用 --from 时默认取文件名。",
"flag.user.login": "用户登录名",
"flag.webhook.active": "Webhook 是否启用true 或 false",
"flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选",

View File

@ -3,15 +3,21 @@ package user
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
{
Name: "me",
Description: "Show current authenticated user",
Description: tr.T("cmd.user.me.short"),
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
@ -22,9 +28,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "info",
Description: "Show user profile",
Description: tr.T("cmd.user.info.short"),
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
@ -39,63 +45,17 @@ func Shortcuts() []*common.Shortcut {
},
},
{
Name: "heatmap",
Description: "Show user contribution heatmap",
Name: "keys",
Description: tr.T("cmd.user.keys.short"),
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "year", Short: "y", Usage: "Year (e.g. 2026)"},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
path := fmt.Sprintf("/users/%s/headmaps", login)
if year := ctx.Arg("year"); year != "" {
q := url.Values{}
q.Set("year", year)
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
}
return ctx.Output(env)
}
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "stats",
Description: "Show user development statistics",
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "start-time", Usage: "Start date (YYYY-MM-DD)"},
{Name: "end-time", Usage: "End date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
if err != nil {
return err
}
path := fmt.Sprintf("/users/%s/statistics/develop", login)
q := url.Values{}
if st := ctx.Arg("start-time"); st != "" {
q.Set("start_time", st)
}
if et := ctx.Arg("end-time"); et != "" {
q.Set("end_time", et)
}
if len(q) > 0 {
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
}
return ctx.Output(env)
}
env, err := ctx.CallAPI("GET", path, nil)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", publicKeysPath(), q)
if err != nil {
return err
}
@ -103,17 +63,52 @@ func Shortcuts() []*common.Shortcut {
},
},
{
Name: "trends",
Description: "Show user project trends",
Name: "add-key",
Description: tr.T("cmd.user.add_key.short"),
Flags: []common.Flag{
{Name: "login", Short: "l", Usage: "User login name", Required: true},
{Name: "title", Short: "t", Usage: tr.T("flag.user.key.title")},
{Name: "key", Short: "k", Usage: tr.T("flag.user.key.content")},
{Name: "from", Short: "f", Usage: tr.T("flag.user.key.from")},
},
Run: func(ctx *common.RuntimeContext) error {
login, err := ctx.RequireArg("login")
key, err := publicKeyContent(ctx.Arg("key"), ctx.Arg("from"))
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s/project_trends", login), nil)
title, err := publicKeyTitle(ctx.Arg("title"), ctx.Arg("from"))
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", publicKeysPath(), map[string]interface{}{
"title": title,
"key": key,
})
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete-key",
Description: tr.T("cmd.user.delete_key.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: tr.T("flag.user.key.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("required flag --id is missing")
}
keyID, err := parsePublicKeyID(id)
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/%d", publicKeysPath(), keyID), nil)
if err != nil {
return err
}
@ -122,3 +117,67 @@ func Shortcuts() []*common.Shortcut {
},
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}
func parsePublicKeyID(raw string) (int, error) {
id, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || id <= 0 {
return 0, fmt.Errorf("SSH key ID must be a positive integer")
}
return id, nil
}
func publicKeysPath() string {
return "/public_keys"
}
func publicKeyTitle(title, from string) (string, error) {
title = strings.TrimSpace(title)
if title != "" {
return title, nil
}
from = strings.TrimSpace(from)
if from != "" {
if base := filepath.Base(from); base != "." && base != string(filepath.Separator) {
return base, nil
}
}
return "", fmt.Errorf("required SSH key title is missing; use --title or provide --from")
}
func publicKeyContent(inline, from string) (string, error) {
inline = strings.TrimSpace(inline)
from = strings.TrimSpace(from)
if inline != "" && from != "" {
return "", fmt.Errorf("use either --key or --from, not both")
}
if from != "" {
content, err := os.ReadFile(from)
if err != nil {
return "", fmt.Errorf("read SSH public key file: %w", err)
}
inline = strings.TrimSpace(string(content))
}
if inline == "" {
return "", fmt.Errorf("required SSH public key content is missing; use --key or --from")
}
if !hasPublicKeyPrefix(inline) {
return "", fmt.Errorf("SSH public key content should start with ssh-rsa, ssh-dss, ssh-ed25519, ecdsa-sha2-, or sk-")
}
return inline, nil
}
func hasPublicKeyPrefix(key string) bool {
for _, prefix := range []string{"ssh-rsa", "ssh-dss", "ssh-ed25519", "ecdsa-sha2-", "sk-"} {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}

View File

@ -4,70 +4,83 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findUserShortcut(t, name)
shortcut := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findUserShortcut(t *testing.T, name string) *common.Shortcut {
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeUserJSON(w http.ResponseWriter, v interface{}) {
func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("write response: %v", err)
}
}
// --- me ---
func TestUserMe(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)
}
writeUserJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"login": "currentuser",
"name": "Current User",
"id": 1,
"id": float64(1),
})
}))
defer server.Close()
if err := runUserShortcut(t, server, "me", nil); err != nil {
err := runShortcut(t, server, "me", nil)
if err != nil {
t.Fatalf("me failed: %v", err)
}
}
// --- info ---
func TestUserInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/alice.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeUserJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"login": "alice",
"name": "Alice",
})
}))
defer server.Close()
if err := runUserShortcut(t, server, "info", map[string]string{"login": "alice"}); err != nil {
err := runShortcut(t, server, "info", map[string]string{"login": "alice"})
if err != nil {
t.Fatalf("info failed: %v", err)
}
}
@ -78,575 +91,221 @@ func TestUserInfoMissingLogin(t *testing.T) {
}))
defer server.Close()
if err := runUserShortcut(t, server, "info", map[string]string{}); err == nil {
err := runShortcut(t, server, "info", map[string]string{})
if err == nil {
t.Fatal("expected error for missing login")
}
}
func TestUserHeadmapShortcutBuildsYearQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/alice/headmaps.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("year") != "2026" {
t.Fatalf("unexpected year query: %q", r.URL.Query().Get("year"))
}
writeUserJSON(w, map[string]interface{}{
"headmaps": []interface{}{
map[string]interface{}{"date": "2026-06-01", "contributions": 5},
},
"total_contributions": 5,
})
}))
defer server.Close()
// --- SSH public keys ---
if err := runUserShortcut(t, server, "headmap", map[string]string{"login": "alice", "year": "2026"}); err != nil {
t.Fatalf("headmap failed: %v", err)
}
}
func TestUserDevelopShortcutBuildsTimeRangeQuery(t *testing.T) {
func TestUserKeysUsesPublicKeysEndpoint(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)
if r.Method != "GET" || r.URL.Path != "/public_keys.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("start_time") != "100" || r.URL.Query().Get("end_time") != "200" {
t.Fatalf("unexpected time range query: %s", r.URL.RawQuery)
if got := r.URL.Query().Get("page"); got != "2" {
t.Fatalf("page query = %q, want 2", got)
}
writeUserJSON(w, map[string]interface{}{
"platform": map[string]interface{}{"activity": 90},
"user": map[string]interface{}{
"activity": 70,
"languages_percent": map[string]interface{}{"Go": 0.8},
"each_language_score": map[string]interface{}{"Go": 88},
if got := r.URL.Query().Get("limit"); got != "50" {
t.Fatalf("limit query = %q, want 50", got)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"public_keys": []interface{}{
map[string]interface{}{"id": 1, "name": "laptop"},
},
})
}))
defer server.Close()
if err := runUserShortcut(t, server, "develop", map[string]string{
"login": "alice",
"start-time": "100",
"end-time": "200",
}); err != nil {
t.Fatalf("develop failed: %v", err)
err := runShortcut(t, server, "keys", map[string]string{
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("keys shortcut failed: %v", err)
}
}
func TestUserTrendsFetchesAllPagesWhenFiltersPresent(t *testing.T) {
requests := 0
func TestUserAddKeySendsTitleAndInlineKey(t *testing.T) {
const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDemoKey user@example.com"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
page := r.URL.Query().Get("page")
switch page {
case "1":
writeUserJSON(w, map[string]interface{}{
"project_trends": []interface{}{
map[string]interface{}{
"id": 1,
"trend_id": 11,
"trend_type": "CommitLog",
"name": "initial commit",
"action_type": "创建了代码提交(Commit)",
"user_login": "alice",
"user_name": "Alice",
"project": map[string]interface{}{
"identifier": "repo-a",
"owner": map[string]interface{}{"login": "Gitlink"},
},
},
},
"total_count": 2,
})
case "2":
writeUserJSON(w, map[string]interface{}{
"project_trends": []interface{}{
map[string]interface{}{
"id": 2,
"trend_id": 12,
"trend_type": "PullRequest",
"name": "improve docs",
"action_type": "创建了合并请求(PR)",
"user_login": "alice",
"user_name": "Alice",
"project": map[string]interface{}{
"identifier": "repo-b",
"owner": map[string]interface{}{"login": "Gitlink"},
},
},
},
"total_count": 2,
})
default:
t.Fatalf("unexpected page query: %s", page)
if r.Method != "POST" || r.URL.Path != "/public_keys.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runUserShortcut(t, server, "trends", map[string]string{
"login": "alice",
"page": "1",
"limit": "1",
"trend-type": "PullRequest",
}); err != nil {
t.Fatalf("trends failed: %v", err)
}
if requests != 2 {
t.Fatalf("expected 2 requests for filtered trends, got %d", requests)
}
}
func TestBuildTimeRangeQueryRejectsInvalidOrder(t *testing.T) {
if _, _, err := buildTimeRangeQuery("200", "100"); err == nil {
t.Fatal("expected invalid time range error")
}
}
func TestNormalizeActivityDataBuildsTimelineSummary(t *testing.T) {
result, err := normalizeActivityData("alice", map[string]interface{}{
"dates": []interface{}{"2026.06.01", "2026.06.02"},
"commits_count": []interface{}{3, 5},
"issues_count": []interface{}{1, 0},
"pull_requests_count": []interface{}{0, 2},
})
if err != nil {
t.Fatalf("normalizeActivityData failed: %v", err)
}
totals := result["totals"].(map[string]interface{})
if totals["all"].(int) != 11 {
t.Fatalf("unexpected total activity count: %#v", totals)
}
peakDay := result["peak_day"].(map[string]interface{})
if peakDay["date"].(string) != "2026.06.02" {
t.Fatalf("unexpected peak day: %#v", peakDay)
}
}
func TestNormalizeDevelopDataSortsLanguages(t *testing.T) {
result, err := normalizeDevelopData("alice", map[string]interface{}{"start_time": 100}, map[string]interface{}{
"platform": map[string]interface{}{
"activity": 90,
},
"user": map[string]interface{}{
"activity": 70,
"languages_percent": map[string]interface{}{"Go": 0.4, "Python": 0.6},
"each_language_score": map[string]interface{}{"Go": 80, "Python": 95},
},
})
if err != nil {
t.Fatalf("normalizeDevelopData failed: %v", err)
}
languages := result["languages"].([]map[string]interface{})
if len(languages) != 2 || languages[0]["name"].(string) != "Python" {
t.Fatalf("languages not sorted by percent: %#v", languages)
}
if result["period"].(map[string]interface{})["start_time"].(int) != 100 {
t.Fatalf("unexpected period: %#v", result["period"])
}
}
func TestNormalizeRoleDataSortsRoleCounts(t *testing.T) {
result, err := normalizeRoleData("alice", nil, map[string]interface{}{
"role": map[string]interface{}{
"owner": map[string]interface{}{"count": 3, "percent": 0.6},
"developer": map[string]interface{}{"count": 2, "percent": 0.4},
},
"total_projects_count": 5,
})
if err != nil {
t.Fatalf("normalizeRoleData failed: %v", err)
}
roles := result["roles"].([]map[string]interface{})
if roles[0]["name"].(string) != "owner" {
t.Fatalf("expected owner to be primary role: %#v", roles)
}
}
func TestNormalizeTrendDataAppliesFilters(t *testing.T) {
result, err := normalizeTrendData("alice", 1, 20, 2, true, trendFilters{TrendType: "PullRequest"}, []interface{}{
map[string]interface{}{
"id": 1,
"trend_id": 11,
"trend_type": "CommitLog",
"name": "initial commit",
"action_type": "创建了代码提交(Commit)",
"user_login": "alice",
"user_name": "Alice",
"project": map[string]interface{}{
"identifier": "repo-a",
"owner": map[string]interface{}{"login": "Gitlink"},
},
},
map[string]interface{}{
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["title"] != "laptop" {
t.Fatalf("title = %q, want laptop", body["title"])
}
if body["key"] != key {
t.Fatalf("key = %q, want %q", body["key"], key)
}
writeJSON(t, w, map[string]interface{}{
"id": 2,
"trend_id": 12,
"trend_type": "PullRequest",
"name": "improve docs",
"action_type": "创建了合并请求(PR)",
"user_login": "alice",
"user_name": "Alice",
"project": map[string]interface{}{
"identifier": "repo-b",
"owner": map[string]interface{}{"login": "Gitlink"},
},
},
"name": "laptop",
"fingerprint": "SHA256:demo",
})
}))
defer server.Close()
err := runShortcut(t, server, "add-key", map[string]string{
"title": "laptop",
"key": key,
})
if err != nil {
t.Fatalf("normalizeTrendData failed: %v", err)
t.Fatalf("add-key shortcut failed: %v", err)
}
}
func TestUserAddKeyReadsKeyFromFileAndDefaultsTitle(t *testing.T) {
const key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDemo user@example.com"
path := filepath.Join(t.TempDir(), "id_rsa.pub")
if err := os.WriteFile(path, []byte(" "+key+"\n"), 0o600); err != nil {
t.Fatalf("write key file: %v", err)
}
items := result["items"].([]map[string]interface{})
if len(items) != 1 || items[0]["trend_type"].(string) != "PullRequest" {
t.Fatalf("unexpected filtered trend items: %#v", items)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/public_keys.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
if body["title"] != "id_rsa.pub" {
t.Fatalf("title = %q, want id_rsa.pub", body["title"])
}
if body["key"] != key {
t.Fatalf("key = %q, want %q", body["key"], key)
}
writeJSON(t, w, map[string]interface{}{"id": 3})
}))
defer server.Close()
err := runShortcut(t, server, "add-key", map[string]string{"from": path})
if err != nil {
t.Fatalf("add-key from file failed: %v", err)
}
}
func TestUserAddKeyRejectsAmbiguousKeySourcesBeforeRequest(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
defer server.Close()
err := runShortcut(t, server, "add-key", map[string]string{
"title": "laptop",
"key": "ssh-ed25519 AAAA",
"from": "id_ed25519.pub",
})
if err == nil {
t.Fatalf("expected ambiguous key source error")
}
if called {
t.Fatalf("server was called for invalid key sources")
}
}
func TestUserAddKeyRejectsInlineKeyWithoutTitleBeforeRequest(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
defer server.Close()
err := runShortcut(t, server, "add-key", map[string]string{"key": "ssh-rsa AAAA"})
if err == nil {
t.Fatalf("expected missing title error")
}
if called {
t.Fatalf("server was called without title")
}
}
func TestUserDeleteKeyUsesPublicKeyID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" || r.URL.Path != "/public_keys/12.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
}))
defer server.Close()
if err := runShortcut(t, server, "delete-key", map[string]string{"id": "12"}); err != nil {
t.Fatalf("delete-key shortcut failed: %v", err)
}
}
func TestUserDeleteKeyRejectsNonNumericIDBeforeRequest(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
defer server.Close()
err := runShortcut(t, server, "delete-key", map[string]string{"id": "12/34"})
if err == nil {
t.Fatalf("expected invalid key id error")
}
if called {
t.Fatalf("server was called for invalid key id")
}
}
func TestPublicKeyContentAcceptsCommonOpenSSHPrefixes(t *testing.T) {
for _, key := range []string{
"ssh-rsa AAAA",
"ssh-dss AAAA",
"ssh-ed25519 AAAA",
"ecdsa-sha2-nistp256 AAAA",
"sk-ssh-ed25519@openssh.com AAAA",
} {
t.Run(key, func(t *testing.T) {
if _, err := publicKeyContent(key, ""); err != nil {
t.Fatalf("publicKeyContent(%q) returned error: %v", key, err)
}
})
}
}
func TestPublicKeyContentRejectsNonPublicKeyPrefix(t *testing.T) {
if _, err := publicKeyContent("not-a-key", ""); err == nil {
t.Fatalf("expected invalid public key prefix error")
}
}
// --- HTTP error paths ---
func TestUserMeHTTPError(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, "me", nil)
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestUserInfoHTTPError(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 := runUserShortcut(t, server, "info", map[string]string{"login": "alice"}); err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestUserStatisticsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
for _, shortcut := range []string{"activity", "headmap", "develop", "role", "major"} {
t.Run(shortcut, func(t *testing.T) {
err := runShortcut(t, server, shortcut, map[string]string{"login": "alice"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
})
}
}
func TestUserActivityUsesLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/users/alice/statistics/activity.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(w, map[string]interface{}{"dates": []string{"2026.06.13"}, "issues_count": []int{1}})
}))
defer server.Close()
if err := runShortcut(t, server, "activity", map[string]string{"login": "alice"}); err != nil {
t.Fatalf("activity failed: %v", err)
}
}
func TestUserHeadmapBuildsYearQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/users/alice/headmaps.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("year"); got != "2026" {
t.Fatalf("year = %q", got)
}
writeJSON(w, map[string]interface{}{"total_contributions": 1, "headmaps": []interface{}{}})
}))
defer server.Close()
if err := runShortcut(t, server, "headmap", map[string]string{"login": "alice", "year": "2026"}); err != nil {
t.Fatalf("headmap failed: %v", err)
}
}
func TestUserDevelopBuildsTimeRangeQuery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/users/alice/statistics/develop.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("start_time"); got != "1717200000" {
t.Fatalf("start_time = %q", got)
}
if got := r.URL.Query().Get("end_time"); got != "1719800000" {
t.Fatalf("end_time = %q", got)
}
writeJSON(w, map[string]interface{}{"user": map[string]interface{}{"activity": 90}})
}))
defer server.Close()
err := runShortcut(t, server, "develop", map[string]string{"login": "alice", "start-time": "1717200000", "end-time": "1719800000"})
if err != nil {
t.Fatalf("develop failed: %v", err)
}
}
func TestUserRoleAndMajorUseStatisticsEndpoints(t *testing.T) {
calls := []string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.URL.Path)
writeJSON(w, map[string]interface{}{"ok": true})
}))
defer server.Close()
if err := runShortcut(t, server, "role", map[string]string{"login": "alice"}); err != nil {
t.Fatalf("role failed: %v", err)
}
if err := runShortcut(t, server, "major", map[string]string{"login": "alice"}); err != nil {
t.Fatalf("major failed: %v", err)
}
want := []string{"/users/alice/statistics/role.json", "/users/alice/statistics/major.json"}
if len(calls) != len(want) {
t.Fatalf("calls = %#v", calls)
}
for i := range want {
if calls[i] != want[i] {
t.Fatalf("calls = %#v, want %#v", calls, want)
}
}
}
func TestUserStatisticsResolveCurrentUser(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
switch requests {
case 1:
if r.URL.Path != "/users/me.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"login": "current"})
case 2:
if r.URL.Path != "/users/current/statistics/activity.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"dates": []interface{}{}})
default:
t.Fatalf("unexpected extra request: %s", r.URL.Path)
}
}))
defer server.Close()
shortcut := findShortcut(t, "activity")
ctx := &common.RuntimeContext{Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Format: "json", Args: map[string]string{}}
if err := shortcut.Run(ctx); err != nil {
t.Fatalf("activity failed: %v", err)
}
}
func TestUserStatisticsValidation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid input should not call API, got %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
cases := []struct {
name string
shortcut string
args map[string]string
}{
{name: "bad year", shortcut: "headmap", args: map[string]string{"login": "alice", "year": "abcd"}},
{name: "negative start", shortcut: "develop", args: map[string]string{"login": "alice", "start-time": "-1"}},
{name: "start after end", shortcut: "role", args: map[string]string{"login": "alice", "start-time": "20", "end-time": "10"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := runShortcut(t, server, tc.shortcut, tc.args); err == nil {
t.Fatal("expected validation error")
}
})
}
}
// --- heatmap ---
func TestUserHeatmap(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/alice/headmaps.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("year") != "" {
t.Fatalf("expected no year query param, got %s", r.URL.Query().Get("year"))
}
writeJSON(w, map[string]interface{}{
"contributions": []interface{}{
map[string]interface{}{"date": "2026-01-01", "count": float64(5)},
},
})
}))
defer server.Close()
err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice"})
if err != nil {
t.Fatalf("heatmap failed: %v", err)
}
}
func TestUserHeatmapWithYear(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/alice/headmaps.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("year") != "2025" {
t.Fatalf("expected year=2025, got %s", r.URL.Query().Get("year"))
}
writeJSON(w, map[string]interface{}{
"contributions": []interface{}{},
})
}))
defer server.Close()
err := runShortcut(t, server, "heatmap", map[string]string{"login": "alice", "year": "2025"})
if err != nil {
t.Fatalf("heatmap with year failed: %v", err)
}
}
func TestUserHeatmapMissingLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected")
}))
defer server.Close()
err := runShortcut(t, server, "heatmap", map[string]string{})
if err == nil {
t.Fatal("expected error for missing login")
}
}
func TestUserHeatmapHTTPError(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, "heatmap", map[string]string{"login": "alice"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
// --- stats ---
func TestUserStats(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)
}
if r.URL.Query().Get("start_time") != "" || r.URL.Query().Get("end_time") != "" {
t.Fatal("expected no time query params")
}
writeJSON(w, map[string]interface{}{
"pull_request_count": float64(10),
"commit_count": float64(42),
})
}))
defer server.Close()
err := runShortcut(t, server, "stats", map[string]string{"login": "alice"})
if err != nil {
t.Fatalf("stats failed: %v", err)
}
}
func TestUserStatsWithTimeRange(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)
}
if r.URL.Query().Get("start_time") != "2026-01-01" {
t.Fatalf("expected start_time=2026-01-01, got %s", r.URL.Query().Get("start_time"))
}
if r.URL.Query().Get("end_time") != "2026-03-31" {
t.Fatalf("expected end_time=2026-03-31, got %s", r.URL.Query().Get("end_time"))
}
writeJSON(w, map[string]interface{}{
"pull_request_count": float64(5),
"commit_count": float64(20),
})
}))
defer server.Close()
err := runShortcut(t, server, "stats", map[string]string{
"login": "alice",
"start-time": "2026-01-01",
"end-time": "2026-03-31",
})
if err != nil {
t.Fatalf("stats with time range failed: %v", err)
}
}
func TestUserStatsMissingLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected")
}))
defer server.Close()
err := runShortcut(t, server, "stats", map[string]string{})
if err == nil {
t.Fatal("expected error for missing login")
}
}
func TestUserStatsHTTPError(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, "stats", map[string]string{"login": "alice"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
// --- trends ---
func TestUserTrends(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/alice/project_trends.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, []interface{}{
map[string]interface{}{"id": float64(1), "name": "created project"},
})
}))
defer server.Close()
err := runShortcut(t, server, "trends", map[string]string{"login": "alice"})
if err != nil {
t.Fatalf("trends failed: %v", err)
}
}
func TestUserTrendsMissingLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call expected")
}))
defer server.Close()
err := runShortcut(t, server, "trends", map[string]string{})
if err == nil {
t.Fatal("expected error for missing login")
}
}
func TestUserTrendsHTTPError(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, "trends", map[string]string{"login": "alice"})
err := runShortcut(t, server, "info", map[string]string{"login": "alice"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}

View File

@ -139,7 +139,7 @@ skills/
| Skill | 说明 | 常用命令 |
|-------|------|----------|
| **gitlink-search** | 搜索功能 | `search +repos`, `search +users` |
| **gitlink-user** | 用户管理 | `user +me`, `user +info` |
| **gitlink-user** | 用户管理 | `user +me`, `user +info`, `user +keys`, `user +add-key`, `user +delete-key` |
| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` |
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |

View File

@ -28,6 +28,20 @@ metadata:
| `user +role` | 查看用户角色定位统计 | 否 |
| `user +major` | 查看用户专业定位 / 项目分类统计 | 否 |
## SSH Public Keys
Use these shortcuts to manage SSH public keys for the current authenticated user:
```bash
gitlink-cli user +keys
gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub
gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub
gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..."
gitlink-cli user +delete-key --id 123
```
`user +add-key` accepts either `--key` or `--from`, not both. When `--from` is used without `--title`, the filename is used as the default title. `user +delete-key` requires a positive integer key ID.
## 使用示例
```bash