From 560ca9b5f0e47f74607417a570c3b6f1ea4f656f Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 15:24:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(attachment):=20=E6=96=B0=E5=A2=9E=20attach?= =?UTF-8?q?ment=20+upload/+download=20=E9=99=84=E4=BB=B6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=91=BD=E4=BB=A4=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++ README.zh-CN.md | 12 ++ internal/client/upload.go | 163 ++++++++++++++++++++ internal/i18n/locales/en-US.json | 9 ++ internal/i18n/locales/zh-CN.json | 9 ++ shortcuts/attachment/attachment.go | 86 +++++++++++ shortcuts/attachment/attachment_test.go | 192 ++++++++++++++++++++++++ shortcuts/register.go | 91 +++++------ 8 files changed, 530 insertions(+), 44 deletions(-) create mode 100644 internal/client/upload.go create mode 100644 shortcuts/attachment/attachment.go create mode 100644 shortcuts/attachment/attachment_test.go diff --git a/README.md b/README.md index e5e4318..e10c5dd 100644 --- a/README.md +++ b/README.md @@ -473,6 +473,18 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --dry-run ``` +### Attachment Upload & Download + +`attachment` gives a scriptable path for large-file transfer instead of the web UI. The uploaded attachment id can be fed to `release +create --attachment-ids`. + +```bash +# Upload a local file as a platform attachment (returns the attachment id) +gitlink-cli attachment +upload -f ./dist/app-v1.0.0.tar.gz -d "v1.0.0 release asset" + +# Download an attachment by id to a local file +gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz +``` + ### CI/CD Operations ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..f8747a8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -464,6 +464,18 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --dry-run ``` +### 附件上传与下载 + +`attachment` 为大文件传输提供可脚本化的 CLI 通道(不必走网页端)。上传返回的附件 id 可直接用于 `release +create --attachment-ids`。 + +```bash +# 上传本地文件为平台附件(返回附件 id) +gitlink-cli attachment +upload -f ./dist/app-v1.0.0.tar.gz -d "v1.0.0 发布产物" + +# 按 id 下载附件到本地文件 +gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz +``` + ### 流水线管理 ```bash diff --git a/internal/client/upload.go b/internal/client/upload.go new file mode 100644 index 0000000..fb41f32 --- /dev/null +++ b/internal/client/upload.go @@ -0,0 +1,163 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/output" +) + +// PostMultipartFile uploads a local file as a multipart/form-data request. +// fileField is the form field name for the file (GitLink expects "file"); +// extra fields (e.g. description) are added as plain form values. +func (c *Client) PostMultipartFile(path, filePath, fileField string, fields map[string]string) (*output.Envelope, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open upload file: %w", err) + } + defer f.Close() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile(fileField, filepath.Base(filePath)) + if err != nil { + return nil, err + } + if _, err := io.Copy(part, f); err != nil { + return nil, fmt.Errorf("read upload file: %w", err) + } + for k, v := range fields { + if v != "" { + if err := writer.WriteField(k, v); err != nil { + return nil, err + } + } + } + if err := writer.Close(); err != nil { + return nil, err + } + + fullURL := c.BaseURL + normalizeAPIPath(c.BaseURL, path) + req, err := http.NewRequest("POST", fullURL, &buf) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + if c.Debug { + fmt.Printf("→ POST %s (multipart, %s)\n", fullURL, filepath.Base(filePath)) + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("upload failed: %w", err) + } + defer resp.Body.Close() + + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if c.Debug { + fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) + } + + if resp.StatusCode >= 400 { + return nil, &APIError{ + StatusCode: resp.StatusCode, + Code: resp.StatusCode, + Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), + } + } + + var raw map[string]interface{} + if err := json.Unmarshal(respData, &raw); err != nil { + return output.SuccessEnvelope(string(respData), nil), nil + } + if status, ok := raw["status"].(float64); ok && status != 0 && status != 200 && status != 201 && status != 1 { + msg, _ := raw["message"].(string) + return output.ErrorEnvelope(int(status), msg, ""), &APIError{ + StatusCode: int(status), + Code: int(status), + Message: msg, + } + } + return output.SuccessEnvelope(raw, nil), nil +} + +// DownloadFile streams a GET response body to destPath and returns the +// number of bytes written. +func (c *Client) DownloadFile(path, destPath string) (int64, error) { + fullURL := c.BaseURL + normalizeAPIPath(c.BaseURL, path) + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return 0, err + } + if c.Debug { + fmt.Printf("→ GET %s (download to %s)\n", fullURL, destPath) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return 0, fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return 0, &APIError{ + StatusCode: resp.StatusCode, + Code: resp.StatusCode, + Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))), + } + } + // Unknown attachment ids fall through to the web frontend, which answers + // 200 with an HTML page; surface that as an error instead of saving it. + ct := resp.Header.Get("Content-Type") + if strings.Contains(ct, "text/html") { + return 0, &APIError{ + StatusCode: resp.StatusCode, + Code: "non_api_response", + Message: "endpoint returned an HTML page instead of file data; check the attachment id", + } + } + // Deleted/unknown attachments answer 200 with a JSON error body + // ({"status":404,"message":"..."}); surface that as an error too. + if strings.Contains(ct, "application/json") { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + var body struct { + Status float64 `json:"status"` + Message string `json:"message"` + } + if err := json.Unmarshal(data, &body); err == nil && body.Status != 0 && body.Status != 200 && body.Status != 201 && body.Status != 1 { + return 0, &APIError{ + StatusCode: int(body.Status), + Code: int(body.Status), + Message: body.Message, + } + } + return 0, &APIError{ + StatusCode: resp.StatusCode, + Code: "non_file_response", + Message: "endpoint returned JSON instead of file data: " + strings.TrimSpace(string(data)), + } + } + + out, err := os.Create(destPath) + if err != nil { + return 0, fmt.Errorf("create output file: %w", err) + } + defer out.Close() + + n, err := io.Copy(out, resp.Body) + if err != nil { + return n, fmt.Errorf("write output file: %w", err) + } + return n, nil +} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..f376ffe 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -1,6 +1,11 @@ { "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", "cmd.api.short": "Make raw API requests to GitLink", + "cmd.attachment.download.long": "Download a platform attachment by id and stream it to a local file.", + "cmd.attachment.download.short": "Download an attachment to a local file", + "cmd.attachment.short": "Attachment upload and download", + "cmd.attachment.upload.long": "Upload a local file to GitLink as an attachment via multipart form data. The returned attachment id can be used with `release +create --attachment-ids`.", + "cmd.attachment.upload.short": "Upload a local file as a platform attachment", "cmd.auth.login.short": "Login to GitLink", "cmd.auth.logout.short": "Logout from GitLink", "cmd.auth.short": "Authentication commands", @@ -123,6 +128,10 @@ "flag.api.body_stdin": "Read request body JSON from stdin", "flag.api.header": "Additional headers (key:value)", "flag.api.query": "Query parameters (key=val&key2=val2)", + "flag.attachment.description": "Attachment description", + "flag.attachment.file": "Path of the local file to upload", + "flag.attachment.id": "Attachment ID", + "flag.attachment.output": "Output file path (defaults to the attachment id)", "flag.auth.token": "Login by pasting an existing token", "flag.branch.from": "Source branch or commit", "flag.branch.name": "Branch name", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..b648195 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -1,6 +1,11 @@ { "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", "cmd.api.short": "向 GitLink 发起原始 API 请求", + "cmd.attachment.download.long": "按 id 下载平台附件并流式写入本地文件。", + "cmd.attachment.download.short": "下载附件到本地文件", + "cmd.attachment.short": "附件上传与下载", + "cmd.attachment.upload.long": "通过 multipart 表单将本地文件上传到 GitLink 作为附件。返回的附件 id 可用于 `release +create --attachment-ids`。", + "cmd.attachment.upload.short": "将本地文件上传为平台附件", "cmd.auth.login.short": "登录 GitLink", "cmd.auth.logout.short": "退出 GitLink 登录", "cmd.auth.short": "认证命令", @@ -123,6 +128,10 @@ "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.header": "附加请求头(key:value)", "flag.api.query": "查询参数(key=val&key2=val2)", + "flag.attachment.description": "附件描述", + "flag.attachment.file": "要上传的本地文件路径", + "flag.attachment.id": "附件 ID", + "flag.attachment.output": "输出文件路径(默认为附件 id)", "flag.auth.token": "通过粘贴已有 Token 登录", "flag.branch.from": "源分支或 Commit", "flag.branch.name": "分支名称", diff --git a/shortcuts/attachment/attachment.go b/shortcuts/attachment/attachment.go new file mode 100644 index 0000000..fadd100 --- /dev/null +++ b/shortcuts/attachment/attachment.go @@ -0,0 +1,86 @@ +// Package attachment implements shortcuts for uploading and downloading +// platform attachments (release assets, issue attachments, etc.). +// +// Upload wraps the multipart POST /api/attachments endpoint and returns the +// attachment id that can be fed to `release +create --attachment-ids`; +// download wraps GET /api/attachments/:uuid and streams the file to +// disk, giving the CLI a scriptable path for large-file transfer instead of +// the web UI. +package attachment + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns attachment upload/download shortcuts. +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } + + return []*common.Shortcut{ + { + Name: "upload", + Description: tr.T("cmd.attachment.upload.short"), + Long: tr.T("cmd.attachment.upload.long"), + Flags: []common.Flag{ + {Name: "file", Short: "f", Usage: tr.T("flag.attachment.file"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.attachment.description")}, + }, + Run: func(ctx *common.RuntimeContext) error { + file, err := ctx.RequireArg("file") + if err != nil { + return err + } + info, err := os.Stat(file) + if err != nil { + return fmt.Errorf("cannot access file %q: %w", file, err) + } + if info.IsDir() { + return fmt.Errorf("%q is a directory, expected a file", file) + } + fields := map[string]string{ + "description": ctx.Arg("description"), + } + env, err := ctx.Client.PostMultipartFile("/attachments", file, "file", fields) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "download", + Description: tr.T("cmd.attachment.download.short"), + Long: tr.T("cmd.attachment.download.long"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.attachment.id"), Required: true}, + {Name: "output", Short: "o", Usage: tr.T("flag.attachment.output")}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + dest := ctx.Arg("output") + if dest == "" { + dest = id + } + n, err := ctx.Client.DownloadFile("/attachments/"+id, dest) + if err != nil { + return err + } + return ctx.OutputData(map[string]interface{}{ + "file": filepath.Clean(dest), + "bytes": n, + }) + }, + }, + } +} diff --git a/shortcuts/attachment/attachment_test.go b/shortcuts/attachment/attachment_test.go new file mode 100644 index 0000000..a67bf4b --- /dev/null +++ b/shortcuts/attachment/attachment_test.go @@ -0,0 +1,192 @@ +package attachment + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "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 TestShortcutsRegistered(t *testing.T) { + shortcuts := Shortcuts() + if len(shortcuts) != 2 { + t.Fatalf("expected 2 shortcuts, got %d", len(shortcuts)) + } + names := map[string]bool{} + for _, s := range shortcuts { + names[s.Name] = true + if s.Description == "" { + t.Fatalf("shortcut %q has empty description", s.Name) + } + } + for _, want := range []string{"upload", "download"} { + if !names[want] { + t.Fatalf("missing shortcut %q", want) + } + } +} + +func findShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newTestContext(t *testing.T, handler http.HandlerFunc, args map[string]string) *common.RuntimeContext { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Format: "json", + Args: args, + } +} + +func TestUploadMultipart(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "asset.txt") + if err := os.WriteFile(src, []byte("hello attachment"), 0644); err != nil { + t.Fatal(err) + } + + var gotFilename, gotDescription string + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + file, header, err := r.FormFile("file") + if err != nil { + t.Fatalf("form file: %v", err) + } + defer file.Close() + data, _ := io.ReadAll(file) + if string(data) != "hello attachment" { + t.Fatalf("unexpected file content: %q", data) + } + gotFilename = header.Filename + gotDescription = r.FormValue("description") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": 123, "filename": header.Filename}) + }, map[string]string{"file": src, "description": "test asset"}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "upload").Run(ctx); err != nil { + t.Fatalf("upload error: %v", err) + } + if gotFilename != "asset.txt" { + t.Fatalf("filename = %q", gotFilename) + } + if gotDescription != "test asset" { + t.Fatalf("description = %q", gotDescription) + } +} + +func TestUploadMissingFile(t *testing.T) { + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("server should not be reached") + }, map[string]string{"file": "/nonexistent/path/file.bin"}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "upload").Run(ctx); err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestUploadRejectsDirectory(t *testing.T) { + dir := t.TempDir() + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("server should not be reached") + }, map[string]string{"file": dir}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "upload").Run(ctx); err == nil { + t.Fatal("expected error for directory") + } +} + +func TestDownloadWritesFile(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/attachments/42" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Write([]byte("binary-content")) + }, map[string]string{"id": "42", "output": dest}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "download").Run(ctx); err != nil { + t.Fatalf("download error: %v", err) + } + data, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(data) != "binary-content" { + t.Fatalf("unexpected content: %q", data) + } +} + +func TestDownloadJSONErrorBody(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Write([]byte(`{"status":404,"message":"不存在或已被删除"}`)) + }, map[string]string{"id": "deleted", "output": dest}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "download").Run(ctx); err == nil { + t.Fatal("expected error for JSON error body") + } + if _, err := os.Stat(dest); err == nil { + t.Fatal("output file should not be created on JSON error body") + } +} + +func TestDownloadHTMLFallback(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte("")) + }, map[string]string{"id": "unknown", "output": dest}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "download").Run(ctx); err == nil { + t.Fatal("expected error for HTML fallback page") + } + if _, err := os.Stat(dest); err == nil { + t.Fatal("output file should not be created on HTML fallback") + } +} + +func TestDownloadHTTPError(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "out.bin") + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + }, map[string]string{"id": "999", "output": dest}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "download").Run(ctx); err == nil { + t.Fatal("expected error for HTTP 404") + } + if _, err := os.Stat(dest); err == nil { + t.Fatal("output file should not be created on HTTP error") + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..2c89d7b 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/attachment" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -36,53 +37,55 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { tr = translators[0] } groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(tr), - "issue": issue.Shortcuts(tr), - "label": label.Shortcuts(), - "license": license.Shortcuts(), - "member": member.Shortcuts(), - "milestone": milestone.Shortcuts(), - "pipeline": pipeline.Shortcuts(), - "pr": pr.Shortcuts(tr), - "profile": profile.Shortcuts(tr), - "release": release.Shortcuts(tr), - "branch": branch.Shortcuts(tr), - "org": org.Shortcuts(tr), - "user": user.Shortcuts(tr), - "search": search.Shortcuts(tr), - "ci": ci.Shortcuts(tr), - "compare": compare.Shortcuts(), - "dataset": dataset.Shortcuts(tr), - "webhook": webhook.Shortcuts(tr), - "wiki": wiki.Shortcuts(), - "health": health.Shortcuts(tr), - "ignore": ignore.Shortcuts(), - "workflow": workflow.Shortcuts(), + "repo": repo.Shortcuts(tr), + "attachment": attachment.Shortcuts(tr), + "issue": issue.Shortcuts(tr), + "label": label.Shortcuts(), + "license": license.Shortcuts(), + "member": member.Shortcuts(), + "milestone": milestone.Shortcuts(), + "pipeline": pipeline.Shortcuts(), + "pr": pr.Shortcuts(tr), + "profile": profile.Shortcuts(tr), + "release": release.Shortcuts(tr), + "branch": branch.Shortcuts(tr), + "org": org.Shortcuts(tr), + "user": user.Shortcuts(tr), + "search": search.Shortcuts(tr), + "ci": ci.Shortcuts(tr), + "compare": compare.Shortcuts(), + "dataset": dataset.Shortcuts(tr), + "webhook": webhook.Shortcuts(tr), + "wiki": wiki.Shortcuts(), + "health": health.Shortcuts(tr), + "ignore": ignore.Shortcuts(), + "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ - "repo": tr.T("cmd.repo.short"), - "issue": tr.T("cmd.issue.short"), - "label": "Issue label operations", - "license": "License operations", - "member": "Repository member operations", - "milestone": "Milestone operations", - "pipeline": "Pipeline operations", - "pr": tr.T("cmd.pr.short"), - "profile": tr.T("cmd.profile.short"), - "release": tr.T("cmd.release.short"), - "branch": tr.T("cmd.branch.short"), - "org": tr.T("cmd.org.short"), - "user": tr.T("cmd.user.short"), - "search": tr.T("cmd.search.short"), - "ci": tr.T("cmd.ci.short"), - "compare": "Compare branches, tags, or commits", - "dataset": tr.T("cmd.dataset.short"), - "webhook": tr.T("cmd.webhook.short"), - "wiki": "Wiki page management", - "health": "Project health data collection", - "ignore": tr.T("cmd.ignore.short"), - "workflow": "AI agent workflow analysis", + "repo": tr.T("cmd.repo.short"), + "attachment": tr.T("cmd.attachment.short"), + "issue": tr.T("cmd.issue.short"), + "label": "Issue label operations", + "license": "License operations", + "member": "Repository member operations", + "milestone": "Milestone operations", + "pipeline": "Pipeline operations", + "pr": tr.T("cmd.pr.short"), + "profile": tr.T("cmd.profile.short"), + "release": tr.T("cmd.release.short"), + "branch": tr.T("cmd.branch.short"), + "org": tr.T("cmd.org.short"), + "user": tr.T("cmd.user.short"), + "search": tr.T("cmd.search.short"), + "ci": tr.T("cmd.ci.short"), + "compare": "Compare branches, tags, or commits", + "dataset": tr.T("cmd.dataset.short"), + "webhook": tr.T("cmd.webhook.short"), + "wiki": "Wiki page management", + "health": "Project health data collection", + "ignore": tr.T("cmd.ignore.short"), + "workflow": "AI agent workflow analysis", } for name, shortcuts := range groups {