Merge PR #274: feat(shortcuts): add template shortcuts

# Conflicts:
#	README.zh-CN.md
#	shortcuts/template/template.go
#	shortcuts/template/template_test.go
This commit is contained in:
wbtiger 2026-07-14 22:50:07 +08:00
commit 3f13d9e838
8 changed files with 425 additions and 253 deletions

View File

@ -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

View File

@ -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 # 注册入口

View File

@ -0,0 +1,10 @@
# Template shortcut
新增 `template` Shortcut 组,补齐 GitLink 项目模板Project TemplatesOpenAPI 的常用操作封装:
- `template +list`
- `template +get`
- `template +create`
- `template +update`
- `template +delete`

View File

@ -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",

View File

@ -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",

View File

@ -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 {

View File

@ -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()
}

View File

@ -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)
}
}