From bfb4ba98bb805d5242438611ed26b2e6f21315bb Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Wed, 27 May 2026 16:39:25 +0800 Subject: [PATCH] feat: add git tag shortcut group --- README.md | 18 ++++ README.zh-CN.md | 18 ++++ doc/changes/tag-shortcut.md | 24 +++++ internal/i18n/locales/en-US.json | 7 ++ internal/i18n/locales/zh-CN.json | 7 ++ shortcuts/register.go | 3 + shortcuts/register_test.go | 2 +- shortcuts/tag/tag.go | 164 ++++++++++++++++++++++++++++ shortcuts/tag/tag_test.go | 179 +++++++++++++++++++++++++++++++ skills/README.md | 3 + skills/gitlink-tag/SKILL.md | 37 +++++++ 11 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 doc/changes/tag-shortcut.md create mode 100644 shortcuts/tag/tag.go create mode 100644 shortcuts/tag/tag_test.go create mode 100644 skills/gitlink-tag/SKILL.md diff --git a/README.md b/README.md index e5e4318..e0c97ee 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | | 🌿 Branch | Create, delete, list, protect, unprotect branches | +| 🏷️ Tag | List, search, view, and delete Git tags | | 🏷️ Release | Create, edit, update, view, delete releases | | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | @@ -453,6 +454,22 @@ gitlink-cli branch +protect --name main gitlink-cli branch +unprotect --name main ``` +### Tag Management + +```bash +# List tags with commit details +gitlink-cli tag +list --owner Gitlink --repo forgeplus --page 1 --limit 20 + +# List tag names without pagination +gitlink-cli tag +names --owner Gitlink --repo forgeplus -k v1 + +# View a tag +gitlink-cli tag +view --owner Gitlink --repo forgeplus --name v1.0.0 + +# Delete a tag +gitlink-cli tag +delete --owner Gitlink --repo forgeplus --name release/v1.0.0 +``` + ### Release Management ```bash @@ -742,6 +759,7 @@ See [skills/README.md](./skills/README.md) for details. | `gitlink-pr` | Pull request operations (create, merge, review, etc.) | | `gitlink-member` | Repository member and invite link management | | `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) | +| `gitlink-tag` | Git tag operations (list, search, view, delete) | | `gitlink-release` | Release management (create, edit, update, view, delete, etc.) | | `gitlink-ci` | CI/CD operations (builds, logs, etc.) | | `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..cff6f90 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -109,6 +109,7 @@ | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | | 🌿 分支 | 创建、删除、保护分支 | +| 🏷️ 标签 | 列出、搜索、查看、删除 Git 标签 | | 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | @@ -444,6 +445,22 @@ 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 tag +list --owner Gitlink --repo forgeplus --page 1 --limit 20 + +# 无分页列出标签名称 +gitlink-cli tag +names --owner Gitlink --repo forgeplus -k v1 + +# 查看标签详情 +gitlink-cli tag +view --owner Gitlink --repo forgeplus --name v1.0.0 + +# 删除标签 +gitlink-cli tag +delete --owner Gitlink --repo forgeplus --name release/v1.0.0 +``` + ### 发布管理 ```bash @@ -615,6 +632,7 @@ git push gitlink | `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) | | `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) | | `gitlink-member` | 仓库成员与邀请链接管理 | +| `gitlink-tag` | Git 标签管理(列表、搜索、查看、删除) | | `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) | | `gitlink-org` | 组织管理(成员、团队等) | | `gitlink-ci` | CI/CD 操作(构建、日志等) | diff --git a/doc/changes/tag-shortcut.md b/doc/changes/tag-shortcut.md new file mode 100644 index 0000000..3500d06 --- /dev/null +++ b/doc/changes/tag-shortcut.md @@ -0,0 +1,24 @@ +# Tag Shortcut + +## Summary + +Adds a `tag` shortcut group for Git repository tags. This complements release management by exposing the underlying tag inspection and deletion APIs directly. + +## Commands + +| Command | Purpose | +|---------|---------| +| `gitlink-cli tag +list` | List repository tags with commit details using the paginated v1 API | +| `gitlink-cli tag +names` | List tag names without pagination and optionally filter by keyword | +| `gitlink-cli tag +view` | View a single tag by name | +| `gitlink-cli tag +delete` | Delete a tag by name | + +## Validation + +- `--page` and `--limit` must be positive integers. +- `--name` is required and trimmed for view/delete operations. +- Tag names are URL path escaped before calling v1 item endpoints, so names such as `release/v1.0.0` do not break routing. + +## Tests + +The unit tests verify paginated listing, name-only lookup, view/delete endpoint paths, tag-name escaping, and invalid input handling. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..a832ee2 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -93,6 +93,11 @@ "cmd.search.repos.short": "Search repositories", "cmd.search.short": "Search operations", "cmd.search.users.short": "Search users", + "cmd.tag.delete.short": "Delete a repository tag", + "cmd.tag.list.short": "List repository tags with commit details", + "cmd.tag.names.short": "List tag names without pagination", + "cmd.tag.short": "Git tag operations", + "cmd.tag.view.short": "View repository tag details", "cmd.user.info.short": "Show user profile", "cmd.user.me.short": "Show current authenticated user", "cmd.user.short": "User operations", @@ -217,6 +222,8 @@ "flag.search.keyword": "Search keyword", "flag.sort_by": "Sort field", "flag.sort_direction": "Sort direction: asc, desc", + "flag.tag.keyword": "Search keyword for tag names", + "flag.tag.name": "Tag name", "flag.user": "User login (default: current user)", "flag.user.login": "User login name", "flag.webhook.active": "Whether the webhook is active: true or false", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..3f88c57 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -93,6 +93,11 @@ "cmd.search.repos.short": "搜索仓库", "cmd.search.short": "搜索操作", "cmd.search.users.short": "搜索用户", + "cmd.tag.delete.short": "删除仓库标签", + "cmd.tag.list.short": "列出仓库标签及提交详情", + "cmd.tag.names.short": "列出标签名称", + "cmd.tag.short": "Git 标签操作", + "cmd.tag.view.short": "查看仓库标签详情", "cmd.user.info.short": "显示用户资料", "cmd.user.me.short": "显示当前认证用户", "cmd.user.short": "用户操作", @@ -217,6 +222,8 @@ "flag.search.keyword": "搜索关键词", "flag.sort_by": "排序字段", "flag.sort_direction": "排序方向:asc、desc", + "flag.tag.keyword": "标签名称搜索关键词", + "flag.tag.name": "标签名称", "flag.user": "用户登录名(默认:当前用户)", "flag.user.login": "用户登录名", "flag.webhook.active": "Webhook 是否启用:true 或 false", diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..abe808b 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/tag" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" @@ -50,6 +51,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": org.Shortcuts(tr), "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), + "tag": tag.Shortcuts(tr), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), @@ -75,6 +77,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": tr.T("cmd.org.short"), "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), + "tag": tr.T("cmd.tag.short"), "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..887e9f3 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -12,7 +12,7 @@ func TestRegisterAll(t *testing.T) { expectedGroups := []string{ "repo", "issue", "label", "license", "pr", "profile", "release", "branch", - "org", "user", "search", "ci", "workflow", + "org", "user", "search", "tag", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", "dataset", "health", "ignore", "wiki", } diff --git a/shortcuts/tag/tag.go b/shortcuts/tag/tag.go new file mode 100644 index 0000000..f1522d8 --- /dev/null +++ b/shortcuts/tag/tag.go @@ -0,0 +1,164 @@ +package tag + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } + return []*common.Shortcut{ + { + Name: "list", + Description: tr.T("cmd.tag.list.short"), + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, + Run: runList, + }, + { + Name: "names", + Description: tr.T("cmd.tag.names.short"), + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: tr.T("flag.tag.keyword")}, + }, + Run: runNames, + }, + { + Name: "view", + Description: tr.T("cmd.tag.view.short"), + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: tr.T("flag.tag.name"), Required: true}, + }, + Run: runView, + }, + { + Name: "delete", + Description: tr.T("cmd.tag.delete.short"), + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: tr.T("flag.tag.name"), Required: true}, + }, + Run: runDelete, + }, + } +} + +func runList(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + query, err := paginationQuery(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPIWithQuery("GET", tagPath(ctx), query) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runNames(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + query := url.Values{} + query.Set("only_name", "true") + if keyword := strings.TrimSpace(ctx.Arg("keyword")); keyword != "" { + query.Set("name", keyword) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/tags", query) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runView(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := requireTagName(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", tagItemPath(ctx, name), nil) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runDelete(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := requireTagName(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", tagItemPath(ctx, name), nil) + if err != nil { + return err + } + return ctx.Output(env) +} + +func tagPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/tags", ctx.Owner, ctx.Repo) +} + +func tagItemPath(ctx *common.RuntimeContext, name string) string { + return fmt.Sprintf("%s/%s", tagPath(ctx), url.PathEscape(name)) +} + +func paginationQuery(ctx *common.RuntimeContext) (url.Values, error) { + page, err := positiveIntArg(defaultString(ctx.Arg("page"), "1"), "page") + if err != nil { + return nil, err + } + limit, err := positiveIntArg(defaultString(ctx.Arg("limit"), "20"), "limit") + if err != nil { + return nil, err + } + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(limit)) + return query, nil +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func positiveIntArg(value, name string) (int, error) { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("invalid --%s %q: use a positive integer", name, value) + } + return parsed, nil +} + +func requireTagName(ctx *common.RuntimeContext) (string, error) { + name, err := ctx.RequireArg("name") + if err != nil { + return "", err + } + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("required flag --name is empty") + } + return name, nil +} diff --git a/shortcuts/tag/tag_test.go b/shortcuts/tag/tag_test.go new file mode 100644 index 0000000..004273e --- /dev/null +++ b/shortcuts/tag/tag_test.go @@ -0,0 +1,179 @@ +package tag + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestTagListUsesV1Pagination(t *testing.T) { + server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/tags.json") + assertEqual(t, r.URL.Query().Get("page"), "2") + assertEqual(t, r.URL.Query().Get("limit"), "50") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "tags": []interface{}{}}) + }) + defer server.Close() + + err := runTagShortcut(t, server, "list", map[string]string{ + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestTagListUsesDefaultPagination(t *testing.T) { + server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/tags.json") + assertEqual(t, r.URL.Query().Get("page"), "1") + assertEqual(t, r.URL.Query().Get("limit"), "20") + writeJSON(t, w, map[string]interface{}{"total_count": 0, "tags": []interface{}{}}) + }) + defer server.Close() + + if err := runTagShortcut(t, server, "list", nil); err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestTagNamesUsesNameSearch(t *testing.T) { + server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/tags.json") + assertEqual(t, r.URL.Query().Get("only_name"), "true") + assertEqual(t, r.URL.Query().Get("name"), "v1") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "tags": []interface{}{map[string]interface{}{"name": "v1.0"}}, + }) + }) + defer server.Close() + + if err := runTagShortcut(t, server, "names", map[string]string{"keyword": " v1 "}); err != nil { + t.Fatalf("names shortcut failed: %v", err) + } +} + +func TestTagViewEscapesTagName(t *testing.T) { + server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/tags/release%2Fv1.0.json") + writeJSON(t, w, map[string]interface{}{"name": "release/v1.0"}) + }) + defer server.Close() + + if err := runTagShortcut(t, server, "view", map[string]string{"name": "release/v1.0"}); err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func TestTagDeleteEscapesTagName(t *testing.T) { + server := newTagTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/v1/owner/repo/tags/release%2Fv1.0.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runTagShortcut(t, server, "delete", map[string]string{"name": "release/v1.0"}); err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } +} + +func TestTagValidation(t *testing.T) { + server := newTagTestServer(t, 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: "invalid page", + shortcut: "list", + args: map[string]string{"page": "0", "limit": "20"}, + }, + { + name: "invalid limit", + shortcut: "list", + args: map[string]string{"page": "1", "limit": "many"}, + }, + { + name: "empty tag name", + shortcut: "view", + args: map[string]string{"name": " "}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runTagShortcut(t, server, tc.shortcut, tc.args); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func runTagShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findTagShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findTagShortcut(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 newTagTestServer(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.EscapedPath() != path { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.EscapedPath()) + } +} + +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 fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/skills/README.md b/skills/README.md index d507074..8a75d49 100644 --- a/skills/README.md +++ b/skills/README.md @@ -83,6 +83,8 @@ skills/ │ ├── SKILL.md # 分支操作指南 │ └── examples/ │ └── branch-workflow.md # 分支工作流 +├── gitlink-tag/ # Git 标签管理 +│ └── SKILL.md # 标签操作指南 ├── gitlink-release/ # 版本发布 │ ├── SKILL.md # Release 操作指南 │ └── references/ # Release 命令参考文档 @@ -132,6 +134,7 @@ skills/ | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | +| **gitlink-tag** | Git 标签管理 | `tag +list`, `tag +names`, `tag +view`, `tag +delete` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` | ### 辅助 Skills diff --git a/skills/gitlink-tag/SKILL.md b/skills/gitlink-tag/SKILL.md new file mode 100644 index 0000000..d20c315 --- /dev/null +++ b/skills/gitlink-tag/SKILL.md @@ -0,0 +1,37 @@ +--- +name: gitlink-tag +version: 1.0.0 +description: "Git tag management: list tags, search names, view tag details, and delete tags." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli tag --help" +--- + +# gitlink-tag + +Use this skill when an agent needs to inspect or manage Git tags in a GitLink repository. + +## Shortcuts + +| Shortcut | Purpose | +|----------|---------| +| `tag +list` | List tags with commit details | +| `tag +names` | List tag names without pagination | +| `tag +view` | View a tag by name | +| `tag +delete` | Delete a tag by name | + +## Examples + +```bash +gitlink-cli tag +list --owner Gitlink --repo forgeplus --page 1 --limit 20 +gitlink-cli tag +names --owner Gitlink --repo forgeplus -k v1 +gitlink-cli tag +view --owner Gitlink --repo forgeplus --name v1.0.0 +gitlink-cli tag +delete --owner Gitlink --repo forgeplus --name release/v1.0.0 +``` + +## Safety Notes + +- Confirm the target repository before deleting a tag. +- Use `tag +names` or `tag +list` first when the exact tag name is unknown. +- Tags may contain slashes; pass the full tag name to `--name`.