From 5ae38821d166193357c84b8a50439000a84d0f9c Mon Sep 17 00:00:00 2001 From: co63oc <4617245+co63oc@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:41:31 +0800 Subject: [PATCH] feat(shortcuts): add template shortcuts --- README.md | 21 +++ README.zh-CN.md | 21 +++ doc/changes/template-shortcut.md | 10 ++ internal/i18n/locales/en-US.json | 11 ++ internal/i18n/locales/zh-CN.json | 11 ++ shortcuts/register.go | 3 + shortcuts/template/template.go | 205 ++++++++++++++++++++++ shortcuts/template/template_test.go | 260 ++++++++++++++++++++++++++++ 8 files changed, 542 insertions(+) create mode 100644 doc/changes/template-shortcut.md create mode 100644 shortcuts/template/template.go create mode 100644 shortcuts/template/template_test.go diff --git a/README.md b/README.md index e5e4318..ea5eb68 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 6a8879d..9a29684 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -406,6 +406,27 @@ gitlink-cli label +update --owner Gitlink --repo forgeplus -i 42 -c "#00FF00" gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 ``` +### 模板管理 + +```bash +# 列出项目模板 +gitlink-cli template +list --owner Gitlink --repo forgeplus + +# 按 ID 获取模板详情 +gitlink-cli template +get --owner Gitlink --repo forgeplus -i 42 + +# 创建 Issue 模板 +gitlink-cli template +create --owner Gitlink --repo forgeplus \ + -t "ProjectTemplates::Issue" -n "缺陷报告" -c "## 问题描述\n## 复现步骤\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 5 +``` + ### Pull Request ```bash 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 0739395..404302b 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -93,6 +93,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", @@ -113,6 +119,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", @@ -228,6 +235,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 2e6fc4d..fbba924 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -93,6 +93,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": "用户操作", @@ -113,6 +119,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": "预览批处理请求,不发送远端请求", @@ -228,6 +235,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 1fedc7e..17e0dde 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -23,6 +23,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" + "github.com/gitlink-org/gitlink-cli/shortcuts/template" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" @@ -58,6 +59,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "health": health.Shortcuts(tr), "ignore": ignore.Shortcuts(), "workflow": workflow.Shortcuts(), + "template": template.Shortcuts(tr), } descriptions := map[string]string{ @@ -83,6 +85,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "health": "Project health data collection", "ignore": tr.T("cmd.ignore.short"), "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 new file mode 100644 index 0000000..9de9fa1 --- /dev/null +++ b/shortcuts/template/template.go @@ -0,0 +1,205 @@ +package template + +import ( + "errors" + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// templateTypes enumerates the supported project template types. +var templateTypes = []string{ + "ProjectTemplates::Issue", + "ProjectTemplates::PullRequest", + "ProjectTemplates::Commit", +} + +// Shortcuts returns project template management shortcuts. +// +// 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: tr.T("cmd.template.list.short"), + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", templatePath(ctx), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "get", + Description: tr.T("cmd.template.get.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.template.id"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", templateItemPath(ctx, id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: tr.T("cmd.template.create.short"), + Flags: []common.Flag{ + {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: tr.T("cmd.template.update.short"), + Flags: []common.Flag{ + {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: tr.T("cmd.template.delete.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.template.id"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", templateItemPath(ctx, id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +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 new file mode 100644 index 0000000..89a4636 --- /dev/null +++ b/shortcuts/template/template_test.go @@ -0,0 +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 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() + + 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) + } + + 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 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 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) + } +} + +// --- 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 newTemplateTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + 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) + } +}