feat(attachment): 新增 attachment +upload/+download 附件上传下载命令组
This commit is contained in:
parent
c09645da62
commit
560ca9b5f0
12
README.md
12
README.md
|
|
@ -473,6 +473,18 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b
|
|||
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --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 <attachment_id> -o ./app-v1.0.0.tar.gz
|
||||
```
|
||||
|
||||
### CI/CD Operations
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -464,6 +464,18 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i <version_id> -b
|
|||
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --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 <attachment_id> -o ./app-v1.0.0.tar.gz
|
||||
```
|
||||
|
||||
### 流水线管理
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "分支名称",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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("<!doctype html><html></html>"))
|
||||
}, 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue