diff --git a/README.md b/README.md index 4f2cf04..f9553b1 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,27 @@ gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 ``` +### Template Management + +```bash +# List project templates +gitlink-cli template +list --owner Gitlink --repo forgeplus + +# Get a template by id +gitlink-cli template +get --owner Gitlink --repo forgeplus -i 42 + +# Create an issue template +gitlink-cli template +create --owner Gitlink --repo forgeplus \ + -t "ProjectTemplates::Issue" -n "Bug Report" -c "## Description\n## Steps to Reproduce\n## Expected Behavior" + +# Update a template +gitlink-cli template +update --owner Gitlink --repo forgeplus \ + -i 5 -t "ProjectTemplates::Issue" -n "Bug Report v2" -c "## Updated content" + +# Delete a template +gitlink-cli template +delete --owner Gitlink --repo forgeplus -i 5 +``` + ### Pull Requests ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index af22816..9a29684 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -108,7 +108,7 @@ | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | -| 🌿 分支 | 创建、删除、恢复、筛选、保护分支,并切换默认分支 | +| 🌿 分支 | 创建、删除、保护分支 | | 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | @@ -119,7 +119,6 @@ | 👤 用户 | 查看用户资料和信息 | | 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 | | 📋 项目管理 | Sprint 管理、看板、周报 | -| 📝 模板 | 管理 Issue 和 Pull Request 项目模板 | | 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes | ## 安装与快速上手 @@ -239,11 +238,6 @@ gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main -# 读取原始文件和常见依赖清单 -gitlink-cli repo +raw --owner Gitlink --repo forgeplus --path LICENSE --ref master -gitlink-cli repo +file-exists --owner Gitlink --repo forgeplus --path package.json --ref master -gitlink-cli repo +manifest --owner Gitlink --repo forgeplus --kind go --ref master - # 查看语言占比 gitlink-cli repo +languages --owner Gitlink --repo forgeplus @@ -348,13 +342,13 @@ gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败 gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7 # 查看 Issue -gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 123 +gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 # 更新 Issue 元数据 gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15 # 关闭 Issue -gitlink-cli issue +close --owner Gitlink --repo forgeplus --number 123 +gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 # 预览批量关闭,不修改数据 gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run @@ -370,39 +364,8 @@ gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 - 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 -# 将筛选后的 Issue 导出为 CSV,便于离线分析或生成周报 -gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword bug --export-format csv --output issues.csv - # 添加评论 -gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "已修复" - -# 查看、更新、删除 Issue 评论/操作记录 -gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment -gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 58 -b "更新评论" --dry-run -gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 58 --dry-run -gitlink-cli issue +comment-children --owner Gitlink --repo forgeplus --number 123 --comment-id 58 - -# 回复评论并携带附件和 @ 用户 -gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "请查看日志" --parent-id 456 --reply-id 456 --attachment-ids 7,8 --receivers alice,bob - -# 列出评论;需要操作记录时可传 --category all -gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment --keyword fixed - -# 更新或删除评论 -gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 456 -b "更新后的评论" -gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 456 - -# 列出评论下的回复 -gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus --number 123 --comment-id 456 - -# 回复评论并列出某条评论的回复 -gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "同意" --reply-to 456 -gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus -n 123 -c 456 - -# 列出、编辑、删除议题评论 -gitlink-cli issue +comments --owner Gitlink --repo forgeplus -n 123 --category comment -gitlink-cli issue +comment-edit --owner Gitlink --repo forgeplus -n 123 -c 456 -b "更新内容" -gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus -n 123 -c 456 +gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" # 列出 Issue 负责人 gitlink-cli issue +assigners --owner Gitlink --repo forgeplus @@ -449,22 +412,19 @@ gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 # 列出项目模板 gitlink-cli template +list --owner Gitlink --repo forgeplus -# 获取模板详情 -gitlink-cli template +get --owner Gitlink --repo forgeplus -i 1 +# 按 ID 获取模板详情 +gitlink-cli template +get --owner Gitlink --repo forgeplus -i 42 # 创建 Issue 模板 gitlink-cli template +create --owner Gitlink --repo forgeplus \ - -t "ProjectTemplates::Issue" -n "Bug 报告" -c "## 描述\n..." - -# 创建 Pull Request 模板 -gitlink-cli template +create --owner Gitlink --repo forgeplus \ - -t "ProjectTemplates::PullRequest" -n "功能 PR" -c "## 概述\n..." + -t "ProjectTemplates::Issue" -n "缺陷报告" -c "## 问题描述\n## 复现步骤\n## 期望行为" # 更新模板 -gitlink-cli template +update --owner Gitlink --repo forgeplus -i 1 -n "新名称" +gitlink-cli template +update --owner Gitlink --repo forgeplus \ + -i 5 -t "ProjectTemplates::Issue" -n "缺陷报告 v2" -c "## 更新后的内容" # 删除模板 -gitlink-cli template +delete --owner Gitlink --repo forgeplus -i 1 +gitlink-cli template +delete --owner Gitlink --repo forgeplus -i 5 ``` ### Pull Request @@ -503,43 +463,6 @@ gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42 # 创建 PR 审查(支持 dry-run 预览) gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" - -# 查看和管理 PR Review 行评论 -gitlink-cli pr +review-comments --owner Gitlink --repo forgeplus -i 42 --state opened -gitlink-cli pr +review-comment --owner Gitlink --repo forgeplus -i 42 --review-id 10 --commit abc123 --line-code abc123_0_10 --path README.md --note "请修改" --dry-run -gitlink-cli pr +review-comment-update --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --state resolved --dry-run -gitlink-cli pr +review-comment-delete --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --dry-run -``` - -### 分支管理 - -```bash -# 列出分支 -gitlink-cli branch +list --owner Gitlink --repo forgeplus - -# 列出已删除分支,或按关键字筛选 -gitlink-cli branch +list --owner Gitlink --repo forgeplus --state deleted --keyword release/ - -# 列出全部分支及归档下载链接 -gitlink-cli branch +all --owner Gitlink --repo forgeplus - -# 创建分支 -gitlink-cli branch +create --name feature/new-feature - -# 删除分支 -gitlink-cli branch +delete --name feature/old-feature - -# 切换默认分支 -gitlink-cli branch +set-default --name main - -# 恢复已删除分支 -gitlink-cli branch +restore --branch-id 7 --name feature/old-feature - -# 设置分支保护 -gitlink-cli branch +protect --name main - -# 移除分支保护 -gitlink-cli branch +unprotect --name main ``` ### 发布管理 @@ -584,25 +507,6 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run ``` -### 项目管理 - -`pm` 暴露 GitLink 项目管理数据,覆盖看板、Sprint、周报、标签、流水线和 Action 运行记录。 -PM 命令需要传入数字项目 ID。 - -```bash -# 查询 PM 看板和 Sprint Issue -gitlink-cli pm +dashboards --project-id 123 --limit 20 -gitlink-cli pm +sprint-issues --project-id 123 --page 1 --limit 20 - -# 查询周报 Issue 和 PM Issue 标签 -gitlink-cli pm +weekly-issues --project-id 123 -gitlink-cli pm +issue-tags --project-id 123 - -# 查询 PM 流水线和 Action 运行记录 -gitlink-cli pm +pipelines --project-id 123 -gitlink-cli pm +action-runs --project-id 123 -``` - ### 忽略文件模板 ```bash @@ -768,7 +672,6 @@ gitlink-cli/ │ ├── org/ # 组织 shortcuts │ ├── ci/ # CI shortcuts │ ├── pipeline/ # Pipeline shortcuts -│ ├── pm/ # 项目管理 shortcuts │ ├── search/ # 搜索 shortcuts │ ├── user/ # 用户 shortcuts │ └── register.go # 注册入口 diff --git a/doc/changes/template-shortcut.md b/doc/changes/template-shortcut.md new file mode 100644 index 0000000..ea6bb0f --- /dev/null +++ b/doc/changes/template-shortcut.md @@ -0,0 +1,10 @@ +# Template shortcut + +新增 `template` Shortcut 组,补齐 GitLink 项目模板(Project Templates)OpenAPI 的常用操作封装: + +- `template +list` +- `template +get` +- `template +create` +- `template +update` +- `template +delete` + diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index cec6193..6a0feb7 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -110,6 +110,12 @@ "cmd.search.repos.short": "Search repositories", "cmd.search.short": "Search operations", "cmd.search.users.short": "Search users", + "cmd.template.create.short": "Create a project template", + "cmd.template.delete.short": "Delete a project template", + "cmd.template.get.short": "Get a project template by id", + "cmd.template.list.short": "List project templates", + "cmd.template.short": "Project template operations", + "cmd.template.update.short": "Update a project template", "cmd.user.info.short": "Show user profile", "cmd.user.me.short": "Show current authenticated user", "cmd.user.short": "User operations", @@ -131,6 +137,7 @@ "error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm", "error.missing_required_flag": "required flag --{name} is missing", "error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", + "error.template.invalid_type": "invalid --type {type}; expected one of {types}", "error.unsupported_language": "unsupported language: {lang}", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", @@ -258,6 +265,10 @@ "flag.webhook.secret": "Webhook secret", "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.template.content": "Template content", + "flag.template.id": "Template ID", + "flag.template.name": "Template name", + "flag.template.type": "Template type, for example: ProjectTemplates::Issue", "flag.webhook.url": "Webhook target URL", "output.auth.env_hint": " Or set {env} environment variable", "output.auth.login_hint": " Run: gitlink-cli auth login", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 64f3611..2a65eb1 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -110,6 +110,12 @@ "cmd.search.repos.short": "搜索仓库", "cmd.search.short": "搜索操作", "cmd.search.users.short": "搜索用户", + "cmd.template.create.short": "创建项目模板", + "cmd.template.delete.short": "删除项目模板", + "cmd.template.get.short": "根据 ID 获取项目模板", + "cmd.template.list.short": "列出项目模板", + "cmd.template.short": "项目模板操作", + "cmd.template.update.short": "更新项目模板", "cmd.user.info.short": "显示用户资料", "cmd.user.me.short": "显示当前认证用户", "cmd.user.short": "用户操作", @@ -131,6 +137,7 @@ "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", + "error.template.invalid_type": "无效的 --type {type};应为以下之一:{types}", "error.unsupported_language": "不支持的语言:{lang}", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", @@ -258,6 +265,10 @@ "flag.webhook.secret": "Webhook 密钥", "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.template.content": "模板内容", + "flag.template.id": "模板 ID", + "flag.template.name": "模板名称", + "flag.template.type": "模板类型,例如:ProjectTemplates::Issue", "flag.webhook.url": "Webhook 目标 URL", "output.auth.env_hint": " 或设置 {env} 环境变量", "output.auth.login_hint": " 运行:gitlink-cli auth login", diff --git a/shortcuts/register.go b/shortcuts/register.go index 33e3360..6145ac8 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -60,6 +60,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ignore": ignore.Shortcuts(), "template": template.Shortcuts(), "workflow": workflow.Shortcuts(), + "template": template.Shortcuts(tr), } descriptions := map[string]string{ @@ -86,6 +87,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "ignore": tr.T("cmd.ignore.short"), "template": "项目模板操作", "workflow": "AI agent workflow analysis", + "template": tr.T("cmd.template.short"), } for name, shortcuts := range groups { diff --git a/shortcuts/template/template.go b/shortcuts/template/template.go index e65f4d4..9de9fa1 100644 --- a/shortcuts/template/template.go +++ b/shortcuts/template/template.go @@ -1,36 +1,33 @@ package template import ( + "errors" "fmt" + "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// v1RepoPath returns the v1 API path prefix: /v1/{owner}/{repo} -func v1RepoPath(ctx *common.RuntimeContext) string { - return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) -} - -// templatePath returns the API path for template collection. -func templatePath(ctx *common.RuntimeContext) string { - return v1RepoPath(ctx) + "/project_templates" -} - -// templateItemPath returns the API path for a single template. -func templateItemPath(ctx *common.RuntimeContext, id string) string { - return templatePath(ctx) + "/" + id +// templateTypes enumerates the supported project template types. +var templateTypes = []string{ + "ProjectTemplates::Issue", + "ProjectTemplates::PullRequest", + "ProjectTemplates::Commit", } // Shortcuts returns project template management shortcuts. // -// Project templates are reusable content templates (e.g., issue templates) -// that can be applied when creating new resources. -func Shortcuts() []*common.Shortcut { +// Project templates provide reusable content scaffolds for issues, pull +// requests and commits. Until now they could only be managed through the raw +// REST API; these shortcuts expose list/get/create/update/delete as +// first-class commands. +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "列出项目模板", - Flags: []common.Flag{}, + Description: tr.T("cmd.template.list.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -44,9 +41,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "get", - Description: "根据 ID 获取项目模板", + Description: tr.T("cmd.template.get.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "模板 ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.template.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -65,83 +62,30 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "创建项目模板", + Description: tr.T("cmd.template.create.short"), Flags: []common.Flag{ - {Name: "type", Short: "t", Usage: "模板类型(如:ProjectTemplates::Issue、ProjectTemplates::PullRequest)", Required: true}, - {Name: "name", Short: "n", Usage: "模板名称", Required: true}, - {Name: "content", Short: "c", Usage: "模板内容(支持 Markdown)", Required: true}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - templateType, err := ctx.RequireArg("type") - if err != nil { - return err - } - name, err := ctx.RequireArg("name") - if err != nil { - return err - } - content, err := ctx.RequireArg("content") - if err != nil { - return err - } - payload := map[string]interface{}{ - "type": templateType, - "name": name, - "content": content, - } - env, err := ctx.CallAPI("POST", templatePath(ctx), payload) - if err != nil { - return err - } - return ctx.Output(env) + {Name: "type", Short: "t", Usage: tr.T("flag.template.type"), Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.template.name"), Required: true}, + {Name: "content", Short: "c", Usage: tr.T("flag.template.content"), Required: true}, }, + Run: runCreate, }, { Name: "update", - Description: "更新项目模板", + Description: tr.T("cmd.template.update.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "模板 ID", Required: true}, - {Name: "type", Short: "t", Usage: "模板类型(如:ProjectTemplates::Issue、ProjectTemplates::PullRequest)"}, - {Name: "name", Short: "n", Usage: "模板名称"}, - {Name: "content", Short: "c", Usage: "模板内容(支持 Markdown)"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - // At least one field must be provided for update - if ctx.Arg("type") == "" && ctx.Arg("name") == "" && ctx.Arg("content") == "" { - return fmt.Errorf("至少需要提供 --type、--name 或 --content 中的一个") - } - payload := map[string]interface{}{} - if t := ctx.Arg("type"); t != "" { - payload["type"] = t - } - if n := ctx.Arg("name"); n != "" { - payload["name"] = n - } - if c := ctx.Arg("content"); c != "" { - payload["content"] = c - } - env, err := ctx.CallAPI("PUT", templateItemPath(ctx, id), payload) - if err != nil { - return err - } - return ctx.Output(env) + {Name: "id", Short: "i", Usage: tr.T("flag.template.id"), Required: true}, + {Name: "type", Short: "t", Usage: tr.T("flag.template.type"), Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.template.name"), Required: true}, + {Name: "content", Short: "c", Usage: tr.T("flag.template.content"), Required: true}, }, + Run: runUpdate, }, { Name: "delete", - Description: "删除项目模板", + Description: tr.T("cmd.template.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "模板 ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.template.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -160,3 +104,102 @@ func Shortcuts() []*common.Shortcut { }, } } + +func runCreate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := buildPayload(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", templatePath(ctx), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + payload, err := buildPayload(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("PUT", templateItemPath(ctx, id), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +// buildPayload collects the required type/name/content fields and validates +// the template type against the known set. +func buildPayload(ctx *common.RuntimeContext) (map[string]interface{}, error) { + typ, err := ctx.RequireArg("type") + if err != nil { + return nil, err + } + if !isValidTemplateType(typ) { + var msg string + if ctx.Tr != nil { + msg = ctx.Tr.Tf("error.template.invalid_type", i18n.Args{ + "type": typ, + "types": fmt.Sprintf("%v", templateTypes), + }) + } else { + msg = fmt.Sprintf("invalid --type %q; expected one of %v", typ, templateTypes) + } + return nil, errors.New(msg) + } + name, err := ctx.RequireArg("name") + if err != nil { + return nil, err + } + content, err := ctx.RequireArg("content") + if err != nil { + return nil, err + } + return map[string]interface{}{ + "type": typ, + "name": name, + "content": content, + }, nil +} + +func isValidTemplateType(value string) bool { + for _, candidate := range templateTypes { + if candidate == value { + return true + } + } + return false +} + +func templatePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/project_templates", ctx.Owner, ctx.Repo) +} + +func templateItemPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/%s", templatePath(ctx), url.PathEscape(id)) +} + +// setQueryIfPresent is a small helper kept for future list-filter extensions. +func setQueryIfPresent(q url.Values, name, value string) { + if value != "" { + q.Set(name, value) + } +} + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/template/template_test.go b/shortcuts/template/template_test.go index 4917ec9..89a4636 100644 --- a/shortcuts/template/template_test.go +++ b/shortcuts/template/template_test.go @@ -1,89 +1,260 @@ package template import ( + "encoding/json" + "net/http" + "net/http/httptest" "testing" + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func TestShortcutsReturnsAllCommands(t *testing.T) { - shortcuts := Shortcuts() +func TestTemplateList(t *testing.T) { + server := newTemplateTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/project_templates.json") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "project_templates": []interface{}{ + map[string]interface{}{ + "id": float64(1), + "type": "ProjectTemplates::Issue", + "name": "bug修复模板", + "content": "## 问题描述\n[描述]", + "created_at": "2026-01-13 17:30:20", + "updated_at": "2026-01-13 17:30:20", + }, + }, + }) + }) + defer server.Close() - expectedCommands := []string{"list", "get", "create", "update", "delete"} - if len(shortcuts) != len(expectedCommands) { - t.Errorf("Expected %d shortcuts, got %d", len(expectedCommands), len(shortcuts)) + err := runTemplateShortcut(t, server, "list", map[string]string{}) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestTemplateGet(t *testing.T) { + server := newTemplateTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/project_templates/42.json") + writeJSON(t, w, map[string]interface{}{ + "project_template": map[string]interface{}{ + "id": float64(42), + "type": "ProjectTemplates::Issue", + "name": "功能请求模板", + "content": "## 功能描述\n[描述]", + "created_at": "2026-01-22 11:15:04", + "updated_at": "2026-01-22 11:15:04", + }, + }) + }) + defer server.Close() + + err := runTemplateShortcut(t, server, "get", map[string]string{"id": "42"}) + if err != nil { + t.Fatalf("get shortcut failed: %v", err) + } +} + +func TestTemplateGetRequiresID(t *testing.T) { + server := newTemplateTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("get without id should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runTemplateShortcut(t, server, "get", map[string]string{}) + if err == nil { + t.Fatal("expected get without id to return an error") + } +} + +func TestTemplateCreatePayload(t *testing.T) { + var payload map[string]interface{} + server := newTemplateTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/v1/owner/repo/project_templates.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runTemplateShortcut(t, server, "create", map[string]string{ + "type": "ProjectTemplates::Issue", + "name": "测试issue模板", + "content": "模板内容", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) } - for i, cmd := range expectedCommands { - if shortcuts[i].Name != cmd { - t.Errorf("Expected shortcut %q at index %d, got %q", cmd, i, shortcuts[i].Name) + assertEqual(t, payload["type"], "ProjectTemplates::Issue") + assertEqual(t, payload["name"], "测试issue模板") + assertEqual(t, payload["content"], "模板内容") +} + +func TestTemplateCreateRejectsInvalidType(t *testing.T) { + server := newTemplateTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid type should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runTemplateShortcut(t, server, "create", map[string]string{ + "type": "InvalidType", + "name": "test", + "content": "content", + }) + if err == nil { + t.Fatal("expected invalid type to return an error") + } +} + +func TestTemplateUpdatePayload(t *testing.T) { + var payload map[string]interface{} + server := newTemplateTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "PUT", "/v1/owner/repo/project_templates/5.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runTemplateShortcut(t, server, "update", map[string]string{ + "id": "5", + "type": "ProjectTemplates::Issue", + "name": "更新模板", + "content": "更新内容", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + assertEqual(t, payload["type"], "ProjectTemplates::Issue") + assertEqual(t, payload["name"], "更新模板") + assertEqual(t, payload["content"], "更新内容") +} + +func TestTemplateDelete(t *testing.T) { + server := newTemplateTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/v1/owner/repo/project_templates/5.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runTemplateShortcut(t, server, "delete", map[string]string{"id": "5"}) + if err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } +} + +func TestTemplateDeleteRequiresID(t *testing.T) { + server := newTemplateTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("delete without id should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runTemplateShortcut(t, server, "delete", map[string]string{}) + if err == nil { + t.Fatal("expected delete without id to return an error") + } +} + +func TestIsValidTemplateType(t *testing.T) { + for _, valid := range templateTypes { + if !isValidTemplateType(valid) { + t.Fatalf("expected %q to be valid", valid) + } + } + invalid := []string{"", "InvalidType", "projecttemplates::issue", "PR"} + for _, v := range invalid { + if isValidTemplateType(v) { + t.Fatalf("expected %q to be invalid", v) } } } -func TestShortcutDescriptions(t *testing.T) { - shortcuts := Shortcuts() - - for _, s := range shortcuts { - if s.Description == "" { - t.Errorf("Shortcut %q has empty description", s.Name) - } +func TestTemplatePath(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "gitlink", Repo: "forgeplus"} + got := templatePath(ctx) + want := "/v1/gitlink/forgeplus/project_templates" + if got != want { + t.Fatalf("got %q, want %q", got, want) } } -func TestRequiredFlags(t *testing.T) { - shortcuts := Shortcuts() - - // Test create command required flags - createCmd := findShortcut(shortcuts, "create") - if createCmd == nil { - t.Fatal("create command not found") +func TestTemplateItemPath(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "gitlink", Repo: "forgeplus"} + got := templateItemPath(ctx, "42") + want := "/v1/gitlink/forgeplus/project_templates/42" + if got != want { + t.Fatalf("got %q, want %q", got, want) } - assertRequiredFlags(t, createCmd, []string{"type", "name", "content"}) - - // Test get command required flags - getCmd := findShortcut(shortcuts, "get") - if getCmd == nil { - t.Fatal("get command not found") - } - assertRequiredFlags(t, getCmd, []string{"id"}) - - // Test update command required flags - updateCmd := findShortcut(shortcuts, "update") - if updateCmd == nil { - t.Fatal("update command not found") - } - assertRequiredFlags(t, updateCmd, []string{"id"}) - - // Test delete command required flags - deleteCmd := findShortcut(shortcuts, "delete") - if deleteCmd == nil { - t.Fatal("delete command not found") - } - assertRequiredFlags(t, deleteCmd, []string{"id"}) } -func findShortcut(shortcuts []*common.Shortcut, name string) *common.Shortcut { - for _, s := range shortcuts { - if s.Name == name { - return s +// --- helpers --- + +func runTemplateShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findTemplateShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + Tr: i18n.Default(), + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findTemplateShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut } } + t.Fatalf("shortcut %q not found", name) return nil } -func assertRequiredFlags(t *testing.T, shortcut *common.Shortcut, expectedFlags []string) { +func newTemplateTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { t.Helper() - for _, expectedFlag := range expectedFlags { - found := false - for _, flag := range shortcut.Flags { - if flag.Name == expectedFlag && flag.Required { - found = true - break - } - } - if !found { - t.Errorf("Shortcut %q: expected required flag %q not found", shortcut.Name, expectedFlag) - } + return httptest.NewServer(handler) +} + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) } }