diff --git a/README.md b/README.md index 448e53b..020da3d 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 5dffc62..9b773ee 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)** @@ -78,11 +78,19 @@ jiangtx
jiangtx +
+ luwanzhou +
luwanzhou +
+
+ whale_hihihi +
whale_hihihi +
## 为什么选择 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 --dry-run +gitlink-cli dataset +delete-attachment --owner me --repo proj --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) ## 许可证 diff --git a/doc/changes/user-key-shortcuts.md b/doc/changes/user-key-shortcuts.md new file mode 100644 index 0000000..98c719a --- /dev/null +++ b/doc/changes/user-key-shortcuts.md @@ -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. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 54c5d25..c1e596f 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 44706bc..5bde7c1 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -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 筛选", diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index f9ecc69..db2a02e 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -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 +} diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index 46f853a..3aad3ab 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -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") } diff --git a/skills/README.md b/skills/README.md index a9102f4..b4d6d5b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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` | diff --git a/skills/gitlink-user/SKILL.md b/skills/gitlink-user/SKILL.md index 7d6e069..55f0492 100644 --- a/skills/gitlink-user/SKILL.md +++ b/skills/gitlink-user/SKILL.md @@ -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