From 560ca9b5f0e47f74607417a570c3b6f1ea4f656f Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 15:24:46 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat(attachment):=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?attachment=20+upload/+download=20=E9=99=84=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=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 { From 0bd100a5e3256d213a6a9ddf2e7b91f6a0312701 Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 15:25:19 +0000 Subject: [PATCH 02/11] =?UTF-8?q?test(shortcuts):=20=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E7=BB=84=E6=95=B0=E6=96=AD=E8=A8=80=E7=BA=B3=E5=85=A5=20attach?= =?UTF-8?q?ment=20=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/register_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..295af4a 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", "wiki", + "dataset", "health", "ignore", "wiki", "attachment", } groupSet := map[string]bool{} From cd5338f303daa1cffdc29c96a6e42d557bf82178 Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 15:43:34 +0000 Subject: [PATCH 03/11] =?UTF-8?q?perf(attachment):=20=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=8C=96=EF=BC=88io.Pipe=20=E9=9B=B6?= =?UTF-8?q?=E5=86=85=E5=AD=98=E7=BC=93=E5=86=B2=EF=BC=89+=20=E5=A4=A7?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=BF=9B=E5=BA=A6=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/client/upload.go | 105 +++++++++++++++++++++++++++------ internal/client/upload_test.go | 55 +++++++++++++++++ 2 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 internal/client/upload_test.go diff --git a/internal/client/upload.go b/internal/client/upload.go index fb41f32..344a64d 100644 --- a/internal/client/upload.go +++ b/internal/client/upload.go @@ -1,7 +1,6 @@ package client import ( - "bytes" "encoding/json" "fmt" "io" @@ -14,6 +13,63 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/output" ) +// progressThreshold is the minimum file size for which upload progress is +// reported on stderr. +const progressThreshold = 1 << 20 // 1 MiB + +// progressReporter prints upload progress to stderr at 10% steps for files +// larger than progressThreshold. It implements io.Writer so it can sit on +// the tee side of the upload stream. +type progressReporter struct { + name string + total int64 + done int64 + lastPct int64 + lastLine int + out io.Writer +} + +func newProgressReporter(name string, total int64) *progressReporter { + return &progressReporter{name: name, total: total, lastPct: -1, out: os.Stderr} +} + +func newProgressReporterTo(name string, total int64, out io.Writer) *progressReporter { + return &progressReporter{name: name, total: total, lastPct: -1, out: out} +} + +func (p *progressReporter) Write(b []byte) (int, error) { + p.done += int64(len(b)) + if p.total >= progressThreshold { + pct := p.done * 100 / p.total + if pct/10 > p.lastPct/10 || (pct == 100 && p.lastPct != 100) { + line := fmt.Sprintf("uploading %s: %d%% (%s / %s)", p.name, pct, formatBytes(p.done), formatBytes(p.total)) + if pad := p.lastLine - len(line); pad > 0 { + line += strings.Repeat(" ", pad) + } + fmt.Fprintf(p.out, "\r%s", line) + p.lastLine = len(line) + if pct >= 100 { + fmt.Fprintln(p.out) + } + p.lastPct = pct + } + } + return len(b), nil +} + +func formatBytes(n int64) string { + switch { + case n >= 1<<30: + return fmt.Sprintf("%.1f GiB", float64(n)/(1<<30)) + case n >= 1<<20: + return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KiB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} + // 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. @@ -24,28 +80,39 @@ func (c *Client) PostMultipartFile(path, filePath, fileField string, fields map[ } defer f.Close() - var buf bytes.Buffer - writer := multipart.NewWriter(&buf) - part, err := writer.CreateFormFile(fileField, filepath.Base(filePath)) + info, err := f.Stat() 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 + return nil, fmt.Errorf("stat upload file: %w", err) } + // Stream the multipart body through a pipe so arbitrarily large files + // are never buffered in memory. + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + progress := newProgressReporter(filepath.Base(filePath), info.Size()) + go func() { + part, err := writer.CreateFormFile(fileField, filepath.Base(filePath)) + if err != nil { + pw.CloseWithError(err) + return + } + if _, err := io.Copy(part, io.TeeReader(f, progress)); err != nil { + pw.CloseWithError(fmt.Errorf("read upload file: %w", err)) + return + } + for k, v := range fields { + if v != "" { + if err := writer.WriteField(k, v); err != nil { + pw.CloseWithError(err) + return + } + } + } + pw.CloseWithError(writer.Close()) + }() + fullURL := c.BaseURL + normalizeAPIPath(c.BaseURL, path) - req, err := http.NewRequest("POST", fullURL, &buf) + req, err := http.NewRequest("POST", fullURL, pr) if err != nil { return nil, err } diff --git a/internal/client/upload_test.go b/internal/client/upload_test.go new file mode 100644 index 0000000..6dc0923 --- /dev/null +++ b/internal/client/upload_test.go @@ -0,0 +1,55 @@ +package client + +import ( + "bytes" + "strings" + "testing" +) + +func TestProgressReporterLargeFile(t *testing.T) { + var buf bytes.Buffer + total := int64(4 << 20) + p := newProgressReporterTo("big.bin", total, &buf) + + chunk := make([]byte, 1<<20) + for i := 0; i < 4; i++ { + if _, err := p.Write(chunk); err != nil { + t.Fatal(err) + } + } + out := buf.String() + if !strings.Contains(out, "uploading big.bin") { + t.Fatalf("missing progress prefix: %q", out) + } + if !strings.Contains(out, "100%") { + t.Fatalf("missing 100%% mark: %q", out) + } + if !strings.Contains(out, "4.0 MiB / 4.0 MiB") { + t.Fatalf("missing byte summary: %q", out) + } +} + +func TestProgressReporterSmallFileSilent(t *testing.T) { + var buf bytes.Buffer + p := newProgressReporterTo("small.txt", 1024, &buf) + if _, err := p.Write(make([]byte, 1024)); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Fatalf("expected no progress output for small file, got %q", buf.String()) + } +} + +func TestFormatBytes(t *testing.T) { + cases := map[int64]string{ + 512: "512 B", + 2 << 10: "2.0 KiB", + 3 << 20: "3.0 MiB", + 5 << 30: "5.0 GiB", + } + for in, want := range cases { + if got := formatBytes(in); got != want { + t.Fatalf("formatBytes(%d) = %q, want %q", in, got, want) + } + } +} From b068b4a775539237e23073ef6c6578c77e6aadb5 Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 15:46:56 +0000 Subject: [PATCH 04/11] =?UTF-8?q?feat(attachment):=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?+delete=20=E6=8C=89=20id/uuid=20=E5=88=A0=E9=99=A4=E9=99=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +++ README.zh-CN.md | 3 +++ internal/i18n/locales/en-US.json | 2 ++ internal/i18n/locales/zh-CN.json | 2 ++ shortcuts/attachment/attachment.go | 19 +++++++++++++++++ shortcuts/attachment/attachment_test.go | 27 ++++++++++++++++++++++--- 6 files changed, 53 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e10c5dd..6392e54 100644 --- a/README.md +++ b/README.md @@ -483,6 +483,9 @@ gitlink-cli attachment +upload -f ./dist/app-v1.0.0.tar.gz -d "v1.0.0 release as # Download an attachment by id to a local file gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz + +# Delete an attachment by id +gitlink-cli attachment +delete -i ``` ### CI/CD Operations diff --git a/README.zh-CN.md b/README.zh-CN.md index f8747a8..b878bf9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -474,6 +474,9 @@ 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 + +# 按 id 删除附件 +gitlink-cli attachment +delete -i ``` ### 流水线管理 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index f376ffe..184ada0 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -1,6 +1,8 @@ { "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.delete.long": "Delete a platform attachment by id or uuid. Only the attachment owner can delete it.", + "cmd.attachment.delete.short": "Delete an attachment by id", "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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index b648195..9e623da 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -1,6 +1,8 @@ { "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", "cmd.api.short": "向 GitLink 发起原始 API 请求", + "cmd.attachment.delete.long": "按 id 或 uuid 删除平台附件。仅附件所有者可删除。", + "cmd.attachment.delete.short": "按 id 删除附件", "cmd.attachment.download.long": "按 id 下载平台附件并流式写入本地文件。", "cmd.attachment.download.short": "下载附件到本地文件", "cmd.attachment.short": "附件上传与下载", diff --git a/shortcuts/attachment/attachment.go b/shortcuts/attachment/attachment.go index fadd100..1677d7b 100644 --- a/shortcuts/attachment/attachment.go +++ b/shortcuts/attachment/attachment.go @@ -82,5 +82,24 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }) }, }, + { + Name: "delete", + Description: tr.T("cmd.attachment.delete.short"), + Long: tr.T("cmd.attachment.delete.long"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.attachment.id"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.Client.Delete("/attachments/"+id, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/attachment/attachment_test.go b/shortcuts/attachment/attachment_test.go index a67bf4b..54a049f 100644 --- a/shortcuts/attachment/attachment_test.go +++ b/shortcuts/attachment/attachment_test.go @@ -16,8 +16,8 @@ import ( func TestShortcutsRegistered(t *testing.T) { shortcuts := Shortcuts() - if len(shortcuts) != 2 { - t.Fatalf("expected 2 shortcuts, got %d", len(shortcuts)) + if len(shortcuts) != 3 { + t.Fatalf("expected 3 shortcuts, got %d", len(shortcuts)) } names := map[string]bool{} for _, s := range shortcuts { @@ -26,7 +26,7 @@ func TestShortcutsRegistered(t *testing.T) { t.Fatalf("shortcut %q has empty description", s.Name) } } - for _, want := range []string{"upload", "download"} { + for _, want := range []string{"upload", "download", "delete"} { if !names[want] { t.Fatalf("missing shortcut %q", want) } @@ -190,3 +190,24 @@ func TestDownloadHTTPError(t *testing.T) { t.Fatal("output file should not be created on HTTP error") } } + +func TestDeleteAttachment(t *testing.T) { + var gotMethod, gotPath string + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"status": 0, "message": "删除成功"}) + }, map[string]string{"id": "abc-uuid"}) + ctx.Tr = i18n.Default() + + if err := findShortcut(t, "delete").Run(ctx); err != nil { + t.Fatalf("delete error: %v", err) + } + if gotMethod != http.MethodDelete { + t.Fatalf("method = %q, want DELETE", gotMethod) + } + if gotPath != "/attachments/abc-uuid.json" { + t.Fatalf("path = %q, want /attachments/abc-uuid.json", gotPath) + } +} From 19209c5ceabe31cb91412bde5226fce2f22b2967 Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 15:56:08 +0000 Subject: [PATCH 05/11] =?UTF-8?q?feat(attachment):=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E6=98=BE=E7=A4=BA=EF=BC=88=E5=90=AB=E6=97=A0?= =?UTF-8?q?=20Content-Length=20=E7=9A=84=E6=8C=89=20MiB=20=E6=AD=A5?= =?UTF-8?q?=E8=BF=9B=E6=A8=A1=E5=BC=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/client/upload.go | 53 ++++++++++++++++++++++++---------- internal/client/upload_test.go | 35 ++++++++++++++++++++-- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/internal/client/upload.go b/internal/client/upload.go index 344a64d..9e3ae75 100644 --- a/internal/client/upload.go +++ b/internal/client/upload.go @@ -17,10 +17,11 @@ import ( // reported on stderr. const progressThreshold = 1 << 20 // 1 MiB -// progressReporter prints upload progress to stderr at 10% steps for files +// progressReporter prints transfer progress to stderr at 10% steps for files // larger than progressThreshold. It implements io.Writer so it can sit on -// the tee side of the upload stream. +// the tee side of the transfer stream. type progressReporter struct { + verb string name string total int64 done int64 @@ -29,34 +30,54 @@ type progressReporter struct { out io.Writer } -func newProgressReporter(name string, total int64) *progressReporter { - return &progressReporter{name: name, total: total, lastPct: -1, out: os.Stderr} +func newProgressReporter(verb, name string, total int64) *progressReporter { + return &progressReporter{verb: verb, name: name, total: total, lastPct: -1, out: os.Stderr} } -func newProgressReporterTo(name string, total int64, out io.Writer) *progressReporter { - return &progressReporter{name: name, total: total, lastPct: -1, out: out} +func newProgressReporterTo(verb, name string, total int64, out io.Writer) *progressReporter { + return &progressReporter{verb: verb, name: name, total: total, lastPct: -1, out: out} } func (p *progressReporter) Write(b []byte) (int, error) { p.done += int64(len(b)) - if p.total >= progressThreshold { + switch { + case p.total >= progressThreshold: pct := p.done * 100 / p.total if pct/10 > p.lastPct/10 || (pct == 100 && p.lastPct != 100) { - line := fmt.Sprintf("uploading %s: %d%% (%s / %s)", p.name, pct, formatBytes(p.done), formatBytes(p.total)) - if pad := p.lastLine - len(line); pad > 0 { - line += strings.Repeat(" ", pad) - } - fmt.Fprintf(p.out, "\r%s", line) - p.lastLine = len(line) + p.print(fmt.Sprintf("%s %s: %d%% (%s / %s)", p.verb, p.name, pct, formatBytes(p.done), formatBytes(p.total))) if pct >= 100 { fmt.Fprintln(p.out) } p.lastPct = pct } + case p.total <= 0: + // Unknown total (e.g. chunked downloads without Content-Length): + // report transferred bytes at every MiB boundary. + if step := p.done / progressThreshold; step > 0 && step > p.lastPct { + p.print(fmt.Sprintf("%s %s: %s", p.verb, p.name, formatBytes(p.done))) + p.lastPct = step + } } return len(b), nil } +// Close finishes an unknown-total progress line with the final byte count. +func (p *progressReporter) Close() error { + if p.total <= 0 && p.done >= progressThreshold { + p.print(fmt.Sprintf("%s %s: %s", p.verb, p.name, formatBytes(p.done))) + fmt.Fprintln(p.out) + } + return nil +} + +func (p *progressReporter) print(line string) { + if pad := p.lastLine - len(line); pad > 0 { + line += strings.Repeat(" ", pad) + } + fmt.Fprintf(p.out, "\r%s", line) + p.lastLine = len(line) +} + func formatBytes(n int64) string { switch { case n >= 1<<30: @@ -89,7 +110,7 @@ func (c *Client) PostMultipartFile(path, filePath, fileField string, fields map[ // are never buffered in memory. pr, pw := io.Pipe() writer := multipart.NewWriter(pw) - progress := newProgressReporter(filepath.Base(filePath), info.Size()) + progress := newProgressReporter("uploading", filepath.Base(filePath), info.Size()) go func() { part, err := writer.CreateFormFile(fileField, filepath.Base(filePath)) if err != nil { @@ -222,7 +243,9 @@ func (c *Client) DownloadFile(path, destPath string) (int64, error) { } defer out.Close() - n, err := io.Copy(out, resp.Body) + progress := newProgressReporter("downloading", filepath.Base(destPath), resp.ContentLength) + n, err := io.Copy(out, io.TeeReader(resp.Body, progress)) + progress.Close() if err != nil { return n, fmt.Errorf("write output file: %w", err) } diff --git a/internal/client/upload_test.go b/internal/client/upload_test.go index 6dc0923..1b4dc90 100644 --- a/internal/client/upload_test.go +++ b/internal/client/upload_test.go @@ -9,7 +9,7 @@ import ( func TestProgressReporterLargeFile(t *testing.T) { var buf bytes.Buffer total := int64(4 << 20) - p := newProgressReporterTo("big.bin", total, &buf) + p := newProgressReporterTo("uploading", "big.bin", total, &buf) chunk := make([]byte, 1<<20) for i := 0; i < 4; i++ { @@ -31,7 +31,7 @@ func TestProgressReporterLargeFile(t *testing.T) { func TestProgressReporterSmallFileSilent(t *testing.T) { var buf bytes.Buffer - p := newProgressReporterTo("small.txt", 1024, &buf) + p := newProgressReporterTo("uploading", "small.txt", 1024, &buf) if _, err := p.Write(make([]byte, 1024)); err != nil { t.Fatal(err) } @@ -40,6 +40,37 @@ func TestProgressReporterSmallFileSilent(t *testing.T) { } } +func TestProgressReporterUnknownTotal(t *testing.T) { + var buf bytes.Buffer + p := newProgressReporterTo("downloading", "chunked.bin", -1, &buf) + chunk := make([]byte, 1<<20) + for i := 0; i < 3; i++ { + if _, err := p.Write(chunk); err != nil { + t.Fatal(err) + } + } + p.Close() + out := buf.String() + if !strings.Contains(out, "downloading chunked.bin") { + t.Fatalf("missing progress prefix: %q", out) + } + if !strings.Contains(out, "3.0 MiB") { + t.Fatalf("missing final byte count: %q", out) + } +} + +func TestProgressReporterUnknownTotalSmallSilent(t *testing.T) { + var buf bytes.Buffer + p := newProgressReporterTo("downloading", "small.bin", -1, &buf) + if _, err := p.Write(make([]byte, 1024)); err != nil { + t.Fatal(err) + } + p.Close() + if buf.Len() != 0 { + t.Fatalf("expected no progress output for small unknown-total transfer, got %q", buf.String()) + } +} + func TestFormatBytes(t *testing.T) { cases := map[int64]string{ 512: "512 B", From 78aee0cf5c65fffa0dca1f1900948a2232ee094e Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 16:02:09 +0000 Subject: [PATCH 06/11] =?UTF-8?q?feat(release):=20+create=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20--attachment-files=20=E6=9C=AC=E5=9C=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=87=AA=E5=8A=A8=E4=B8=8A=E4=BC=A0=E5=B9=B6=E9=99=84?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/i18n/locales/en-US.json | 1 + internal/i18n/locales/zh-CN.json | 1 + shortcuts/release/release.go | 52 +++++++++++++++++++++++++++- shortcuts/release/release_test.go | 57 +++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 184ada0..44e3f14 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -211,6 +211,7 @@ "flag.profile.start_time": "Start time (Unix timestamp)", "flag.profile.user": "Target user login (defaults to the authenticated user)", "flag.profile.year": "Year for the contribution heatmap (e.g. 2025)", + "flag.release.attachment_files": "Comma-separated local files to upload and attach", "flag.release.body": "Release notes", "flag.release.id": "Release ID", "flag.release.id_or_tag": "Release ID or tag", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 9e623da..5052aad 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -211,6 +211,7 @@ "flag.profile.start_time": "开始时间(Unix 时间戳)", "flag.profile.user": "目标用户登录名(默认为当前认证用户)", "flag.profile.year": "贡献热力图的年份(如 2025)", + "flag.release.attachment_files": "以逗号分隔的本地文件路径,自动上传并附加到发行版", "flag.release.body": "发布说明", "flag.release.id": "发布 ID", "flag.release.id_or_tag": "发布 ID 或标签", diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 21d7ec4..f24e0bb 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -3,6 +3,7 @@ package release import ( "fmt" "net/url" + "os" "strconv" "strings" @@ -46,6 +47,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { {Name: "prerelease", Usage: tr.T("flag.release.prerelease"), Default: "false"}, {Name: "draft", Usage: "Mark as draft (true/false)", Default: "false"}, {Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, + {Name: "attachment-files", Usage: tr.T("flag.release.attachment_files")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -79,11 +81,21 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if t := ctx.Arg("target"); t != "" { payload["target_commitish"] = t } + var ids []string if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" { - ids, err := parseReleaseAttachmentIDs(attachmentIDs) + ids, err = parseReleaseAttachmentIDs(attachmentIDs) if err != nil { return err } + } + if files := ctx.Arg("attachment-files"); files != "" { + uploaded, err := uploadReleaseAttachments(ctx, files) + if err != nil { + return err + } + ids = append(ids, uploaded...) + } + if len(ids) > 0 { payload["attachment_ids"] = ids } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload) @@ -424,3 +436,41 @@ func firstReleaseValue(values ...string) string { } return "" } + +// uploadReleaseAttachments uploads local files given as a comma-separated +// list and returns their attachment ids for use in attachment_ids. +func uploadReleaseAttachments(ctx *common.RuntimeContext, files string) ([]string, error) { + var ids []string + for _, part := range strings.Split(files, ",") { + file := strings.TrimSpace(part) + if file == "" { + continue + } + info, err := os.Stat(file) + if err != nil { + return nil, fmt.Errorf("cannot access file %q: %w", file, err) + } + if info.IsDir() { + return nil, fmt.Errorf("%q is a directory, expected a file", file) + } + env, err := ctx.Client.PostMultipartFile("/attachments", file, "file", nil) + if err != nil { + return nil, fmt.Errorf("upload %q failed: %w", file, err) + } + data, _ := env.Data.(map[string]interface{}) + id, _ := data["id"].(string) + if id == "" { + if num, ok := data["id"].(float64); ok { + id = strconv.FormatFloat(num, 'f', -1, 64) + } + } + if id == "" { + return nil, fmt.Errorf("upload %q succeeded but no attachment id was returned", file) + } + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, fmt.Errorf("--attachment-files must include at least one file") + } + return ids, nil +} diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go index aa31664..3038292 100644 --- a/shortcuts/release/release_test.go +++ b/shortcuts/release/release_test.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "testing" @@ -453,3 +455,58 @@ func ExampleShortcuts() { // update // delete } + +func TestReleaseCreateWithAttachmentFiles(t *testing.T) { + dir := t.TempDir() + asset := filepath.Join(dir, "asset.bin") + if err := os.WriteFile(asset, []byte("release asset data"), 0644); err != nil { + t.Fatal(err) + } + + var payload map[string]interface{} + var uploads int + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/attachments" || r.URL.Path == "/attachments.json" { + uploads++ + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + writeReleaseJSON(t, w, map[string]interface{}{"id": "uuid-from-upload", "title": "asset.bin"}) + return + } + assertReleaseRequest(t, r, "POST", "/owner/repo/releases.json") + payload = decodeReleaseJSON(t, r) + writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "created"}) + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "create", map[string]string{ + "tag": "v1.0.0", + "name": "v1.0.0", + "attachment-ids": "12", + "attachment-files": asset, + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + if uploads != 1 { + t.Fatalf("uploads = %d, want 1", uploads) + } + assertReleaseStringSlice(t, payload["attachment_ids"], []string{"12", "uuid-from-upload"}) +} + +func TestReleaseCreateAttachmentFilesMissing(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "create", map[string]string{ + "tag": "v1.0.0", + "name": "v1.0.0", + "attachment-files": "/nonexistent/path.bin", + }) + if err == nil { + t.Fatal("expected error for missing attachment file") + } +} From 34c8a0f0b6e8673114956a4449feaa579c765a0c Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 16:02:36 +0000 Subject: [PATCH 07/11] =?UTF-8?q?docs:=20README=20=E4=B8=AD=E8=8B=B1?= =?UTF-8?q?=E8=A1=A5=20--attachment-files=20=E4=B8=80=E6=AD=A5=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +++ README.zh-CN.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/README.md b/README.md index 6392e54..9d4e9b1 100644 --- a/README.md +++ b/README.md @@ -486,6 +486,9 @@ gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz # Delete an attachment by id gitlink-cli attachment +delete -i + +# Or do it in one step: upload local files and attach them to a new release +gitlink-cli release +create -t v1.0.0 -n "v1.0.0" --attachment-files ./dist/app.tar.gz,./dist/app.sha256 ``` ### CI/CD Operations diff --git a/README.zh-CN.md b/README.zh-CN.md index b878bf9..86c1e74 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -477,6 +477,9 @@ gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz # 按 id 删除附件 gitlink-cli attachment +delete -i + +# 一步到位:上传本地文件并附加到新发行版 +gitlink-cli release +create -t v1.0.0 -n "v1.0.0" --attachment-files ./dist/app.tar.gz,./dist/app.sha256 ``` ### 流水线管理 From 682604f23888c386ce02a6d1222e32141f5a3df0 Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 16:30:00 +0000 Subject: [PATCH 08/11] =?UTF-8?q?feat(attachment):=20+upload=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E6=96=87=E4=BB=B6=E5=B9=B6=E5=8F=91=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=EF=BC=88-c=20=E5=B9=B6=E5=8F=91=E6=95=B0=EF=BC=8C?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=203=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + README.zh-CN.md | 3 + internal/client/client.go | 3 + internal/client/upload.go | 12 +++- internal/i18n/locales/en-US.json | 1 + internal/i18n/locales/zh-CN.json | 1 + shortcuts/attachment/attachment.go | 92 ++++++++++++++++++++++--- shortcuts/attachment/attachment_test.go | 57 +++++++++++++++ 8 files changed, 161 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9d4e9b1..458eda6 100644 --- a/README.md +++ b/README.md @@ -481,6 +481,9 @@ gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --d # 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" +# Upload several files concurrently (comma-separated; -c sets the worker count, default 3) +gitlink-cli attachment +upload -f ./dist/app.tar.gz,./dist/app.sha256,./dist/CHANGELOG.md -c 3 + # Download an attachment by id to a local file gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz diff --git a/README.zh-CN.md b/README.zh-CN.md index 86c1e74..64af170 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -472,6 +472,9 @@ gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --d # 上传本地文件为平台附件(返回附件 id) gitlink-cli attachment +upload -f ./dist/app-v1.0.0.tar.gz -d "v1.0.0 发布产物" +# 多文件并发上传(逗号分隔;-c 指定并发数,默认 3) +gitlink-cli attachment +upload -f ./dist/app.tar.gz,./dist/app.sha256,./dist/CHANGELOG.md -c 3 + # 按 id 下载附件到本地文件 gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz diff --git a/internal/client/client.go b/internal/client/client.go index 1fb9d80..e26086c 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -18,6 +18,9 @@ type Client struct { HTTP *http.Client BaseURL string Debug bool + // NoProgress suppresses per-byte transfer progress on stderr; used when + // several transfers run concurrently and interleaved lines would garble. + NoProgress bool } type APIError struct { diff --git a/internal/client/upload.go b/internal/client/upload.go index 9e3ae75..15be88c 100644 --- a/internal/client/upload.go +++ b/internal/client/upload.go @@ -30,6 +30,14 @@ type progressReporter struct { out io.Writer } +// transferProgress builds a progress reporter honoring Client.NoProgress. +func (c *Client) transferProgress(verb, name string, total int64) *progressReporter { + if c.NoProgress { + return newProgressReporterTo(verb, name, total, io.Discard) + } + return newProgressReporter(verb, name, total) +} + func newProgressReporter(verb, name string, total int64) *progressReporter { return &progressReporter{verb: verb, name: name, total: total, lastPct: -1, out: os.Stderr} } @@ -110,7 +118,7 @@ func (c *Client) PostMultipartFile(path, filePath, fileField string, fields map[ // are never buffered in memory. pr, pw := io.Pipe() writer := multipart.NewWriter(pw) - progress := newProgressReporter("uploading", filepath.Base(filePath), info.Size()) + progress := c.transferProgress("uploading", filepath.Base(filePath), info.Size()) go func() { part, err := writer.CreateFormFile(fileField, filepath.Base(filePath)) if err != nil { @@ -243,7 +251,7 @@ func (c *Client) DownloadFile(path, destPath string) (int64, error) { } defer out.Close() - progress := newProgressReporter("downloading", filepath.Base(destPath), resp.ContentLength) + progress := c.transferProgress("downloading", filepath.Base(destPath), resp.ContentLength) n, err := io.Copy(out, io.TeeReader(resp.Body, progress)) progress.Close() if err != nil { diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 44e3f14..35bec1d 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -130,6 +130,7 @@ "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.concurrency": "Concurrent uploads when passing multiple comma-separated files (default 3)", "flag.attachment.description": "Attachment description", "flag.attachment.file": "Path of the local file to upload", "flag.attachment.id": "Attachment ID", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 5052aad..0d4acd0 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -130,6 +130,7 @@ "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.header": "附加请求头(key:value)", "flag.api.query": "查询参数(key=val&key2=val2)", + "flag.attachment.concurrency": "多文件(逗号分隔)上传时的并发数(默认 3)", "flag.attachment.description": "附件描述", "flag.attachment.file": "要上传的本地文件路径", "flag.attachment.id": "附件 ID", diff --git a/shortcuts/attachment/attachment.go b/shortcuts/attachment/attachment.go index 1677d7b..a38f439 100644 --- a/shortcuts/attachment/attachment.go +++ b/shortcuts/attachment/attachment.go @@ -12,11 +12,70 @@ import ( "fmt" "os" "path/filepath" + "strconv" + "strings" + "sync" "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// splitFiles parses a comma-separated file list, trimming blanks. +func splitFiles(arg string) []string { + var files []string + for _, f := range strings.Split(arg, ",") { + if f = strings.TrimSpace(f); f != "" { + files = append(files, f) + } + } + return files +} + +// uploadConcurrently uploads several files with a bounded worker pool. +// Per-byte progress is suppressed (interleaved lines would garble); instead +// one line per completed file goes to stderr. Results keep input order. +func uploadConcurrently(ctx *common.RuntimeContext, files []string, fields map[string]string, concurrency int) ([]interface{}, error) { + quiet := *ctx.Client + quiet.NoProgress = true + + type result struct { + env *output.Envelope + err error + } + results := make([]result, len(files)) + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + var mu sync.Mutex + for i, file := range files { + wg.Add(1) + go func(i int, file string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + env, err := quiet.PostMultipartFile("/attachments", file, "file", fields) + results[i] = result{env: env, err: err} + mu.Lock() + if err != nil { + fmt.Fprintf(os.Stderr, "uploaded %s: error: %v\n", filepath.Base(file), err) + } else { + fmt.Fprintf(os.Stderr, "uploaded %s\n", filepath.Base(file)) + } + mu.Unlock() + }(i, file) + } + wg.Wait() + + out := make([]interface{}, 0, len(files)) + for i, r := range results { + if r.err != nil { + return nil, fmt.Errorf("upload %q failed: %w", files[i], r.err) + } + out = append(out, map[string]interface{}{"file": files[i], "result": r.env.Data}) + } + return out, nil +} + // Shortcuts returns attachment upload/download shortcuts. func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := i18n.Default() @@ -32,27 +91,42 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { 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")}, + {Name: "concurrency", Short: "c", Usage: tr.T("flag.attachment.concurrency"), Default: "3"}, }, Run: func(ctx *common.RuntimeContext) error { - file, err := ctx.RequireArg("file") + fileArg, 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) + files := splitFiles(fileArg) + for _, file := range files { + 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 len(files) == 1 { + env, err := ctx.Client.PostMultipartFile("/attachments", files[0], "file", fields) + if err != nil { + return err + } + return ctx.Output(env) + } + concurrency, err := strconv.Atoi(ctx.Arg("concurrency")) + if err != nil || concurrency < 1 { + concurrency = 3 + } + results, err := uploadConcurrently(ctx, files, fields, concurrency) if err != nil { return err } - return ctx.Output(env) + return ctx.OutputData(results) }, }, { diff --git a/shortcuts/attachment/attachment_test.go b/shortcuts/attachment/attachment_test.go index 54a049f..2629244 100644 --- a/shortcuts/attachment/attachment_test.go +++ b/shortcuts/attachment/attachment_test.go @@ -2,11 +2,14 @@ package attachment import ( "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" + "sync" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -211,3 +214,57 @@ func TestDeleteAttachment(t *testing.T) { t.Fatalf("path = %q, want /attachments/abc-uuid.json", gotPath) } } + +func TestSplitFiles(t *testing.T) { + got := splitFiles(" a.txt, b.bin ,,c ") + want := []string{"a.txt", "b.bin", "c"} + if len(got) != len(want) { + t.Fatalf("splitFiles = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("splitFiles[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestUploadMultipleFilesConcurrently(t *testing.T) { + dir := t.TempDir() + var files []string + for _, name := range []string{"one.txt", "two.txt", "three.txt"} { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("data-"+name), 0o644); err != nil { + t.Fatal(err) + } + files = append(files, p) + } + + var mu sync.Mutex + seen := map[string]bool{} + ctx := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/attachments" && r.URL.Path != "/attachments.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("parse multipart: %v", err) + } + _, hdr, err := r.FormFile("file") + if err != nil { + t.Fatalf("form file: %v", err) + } + mu.Lock() + seen[hdr.Filename] = true + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%q,"msg":"success"}`, hdr.Filename) + }, map[string]string{ + "file": strings.Join(files, ","), + "concurrency": "2", + }) + if err := findShortcut(t, "upload").Run(ctx); err != nil { + t.Fatalf("multi-file upload failed: %v", err) + } + if len(seen) != 3 { + t.Fatalf("uploaded %d files, want 3: %v", len(seen), seen) + } +} From b4e66f1eaacc45f0ee4c61bf3151e7269f1c5754 Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 16:37:54 +0000 Subject: [PATCH 09/11] =?UTF-8?q?feat(release):=20+download=20=E4=B8=80?= =?UTF-8?q?=E6=AD=A5=E4=B8=8B=E8=BD=BD=E5=8F=91=E8=A1=8C=E7=89=88=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E9=99=84=E4=BB=B6=EF=BC=88tag/id=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=20version=5Fid=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++ README.zh-CN.md | 3 ++ internal/i18n/locales/en-US.json | 4 ++ internal/i18n/locales/zh-CN.json | 4 ++ shortcuts/release/release.go | 90 +++++++++++++++++++++++++++++++ shortcuts/release/release_test.go | 75 +++++++++++++++++++++++++- 6 files changed, 178 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 458eda6..e8c9475 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,9 @@ gitlink-cli attachment +upload -f ./dist/app.tar.gz,./dist/app.sha256,./dist/CHA # Download an attachment by id to a local file gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz +# Download all attachments of a release by tag (mirrors `gh release download`) +gitlink-cli release +download -i v1.0.0 -o ./assets + # Delete an attachment by id gitlink-cli attachment +delete -i diff --git a/README.zh-CN.md b/README.zh-CN.md index 64af170..577abf0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -478,6 +478,9 @@ gitlink-cli attachment +upload -f ./dist/app.tar.gz,./dist/app.sha256,./dist/CHA # 按 id 下载附件到本地文件 gitlink-cli attachment +download -i -o ./app-v1.0.0.tar.gz +# 按 tag 一步下载发行版全部附件(对标 `gh release download`) +gitlink-cli release +download -i v1.0.0 -o ./assets + # 按 id 删除附件 gitlink-cli attachment +delete -i diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 35bec1d..cedbc04 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -85,6 +85,8 @@ "cmd.profile.short": "User profile and statistics operations", "cmd.release.create.short": "Create a release", "cmd.release.delete.short": "Delete a release", + "cmd.release.download.long": "Fetch the release by id or tag and download every attachment to a local directory, mirroring `gh release download`.", + "cmd.release.download.short": "Download all attachments of a release", "cmd.release.list.short": "List releases", "cmd.release.short": "Release operations", "cmd.release.view.short": "View release details", @@ -120,6 +122,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.release.no_attachments": "the release has no attachments to download", "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", @@ -217,6 +220,7 @@ "flag.release.id": "Release ID", "flag.release.id_or_tag": "Release ID or tag", "flag.release.name": "Release name", + "flag.release.output_dir": "Directory to save downloaded files (default current directory)", "flag.release.prerelease": "Mark as prerelease (true/false)", "flag.release.tag": "Tag name", "flag.release.target": "Target branch", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 0d4acd0..5383788 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -85,6 +85,8 @@ "cmd.profile.short": "用户画像与统计操作", "cmd.release.create.short": "创建发布", "cmd.release.delete.short": "删除发布", + "cmd.release.download.long": "按 id 或 tag 获取发行版并把全部附件下载到本地目录,对标 `gh release download`。", + "cmd.release.download.short": "下载发行版的全部附件", "cmd.release.list.short": "列出发布", "cmd.release.short": "发布操作", "cmd.release.view.short": "查看发布详情", @@ -120,6 +122,7 @@ "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", + "error.release.no_attachments": "该发行版没有可下载的附件", "error.unsupported_language": "不支持的语言:{lang}", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", @@ -217,6 +220,7 @@ "flag.release.id": "发布 ID", "flag.release.id_or_tag": "发布 ID 或标签", "flag.release.name": "发布名称", + "flag.release.output_dir": "下载文件保存目录(默认当前目录)", "flag.release.prerelease": "标记为预发布(true/false)", "flag.release.tag": "标签名称", "flag.release.target": "目标分支", diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index f24e0bb..891ca3c 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -1,9 +1,11 @@ package release import ( + "errors" "fmt" "net/url" "os" + "path/filepath" "strconv" "strings" @@ -12,6 +14,37 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// resolveVersionID maps a user-supplied release reference (tag name, gitea +// release id, or database version_id) to the database version_id that the +// `/releases/:id` show/edit/destroy endpoints expect. The list endpoint is +// the only one exposing both identifiers, so we page through it and match. +func resolveVersionID(ctx *common.RuntimeContext, ref string) (string, error) { + for page := 1; page <= 100; page++ { + q := url.Values{} + q.Set("page", strconv.Itoa(page)) + q.Set("limit", "50") + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) + if err != nil { + return "", err + } + data, _ := env.Data.(map[string]interface{}) + releases, _ := data["releases"].([]interface{}) + if len(releases) == 0 { + break + } + for _, r := range releases { + rel, _ := r.(map[string]interface{}) + tag, _ := rel["tag_name"].(string) + gid := fmt.Sprintf("%v", rel["id"]) + versionID := fmt.Sprintf("%v", rel["version_id"]) + if ref == tag || ref == gid || ref == versionID { + return versionID, nil + } + } + } + return "", fmt.Errorf("release %q not found", ref) +} + func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ @@ -147,6 +180,63 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "download", + Description: tr.T("cmd.release.download.short"), + Long: tr.T("cmd.release.download.long"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, + {Name: "output-dir", Short: "o", Usage: tr.T("flag.release.output_dir"), Default: "."}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + versionID, err := resolveVersionID(ctx, id) + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), versionID), nil) + if err != nil { + return err + } + data, _ := env.Data.(map[string]interface{}) + attachments, _ := data["attachments"].([]interface{}) + if len(attachments) == 0 { + return errors.New(tr.T("error.release.no_attachments")) + } + outDir := ctx.Arg("output-dir") + if err := os.MkdirAll(outDir, 0o755); err != nil { + return fmt.Errorf("create output dir %q: %w", outDir, err) + } + var downloaded []map[string]interface{} + for _, a := range attachments { + att, _ := a.(map[string]interface{}) + title, _ := att["title"].(string) + attID := fmt.Sprintf("%v", att["id"]) + if title == "" || attID == "" || att["id"] == nil { + continue + } + dest := filepath.Join(outDir, filepath.Base(title)) + n, err := ctx.Client.DownloadFile("/attachments/"+attID, dest) + if err != nil { + return fmt.Errorf("download %q failed: %w", title, err) + } + downloaded = append(downloaded, map[string]interface{}{ + "file": dest, + "bytes": n, + }) + } + return ctx.OutputData(map[string]interface{}{ + "release": id, + "files": downloaded, + }) + }, + }, { Name: "update", Description: "Update a release while preserving unspecified fields", diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go index 3038292..66ad35e 100644 --- a/shortcuts/release/release_test.go +++ b/shortcuts/release/release_test.go @@ -331,7 +331,7 @@ func TestReleaseShortcutNames(t *testing.T) { for _, shortcut := range Shortcuts() { got[shortcut.Name] = true } - want := []string{"list", "create", "edit", "view", "update", "delete"} + want := []string{"list", "create", "edit", "view", "download", "update", "delete"} for _, name := range want { if !got[name] { t.Fatalf("missing shortcut %q in %v", name, got) @@ -452,6 +452,7 @@ func ExampleShortcuts() { // create // edit // view + // download // update // delete } @@ -510,3 +511,75 @@ func TestReleaseCreateAttachmentFilesMissing(t *testing.T) { t.Fatal("expected error for missing attachment file") } } + +func TestReleaseDownloadAllAttachments(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/owner/repo/releases.json": + if r.URL.Query().Get("page") != "1" { + writeReleaseJSON(t, w, map[string]interface{}{"releases": []interface{}{}}) + return + } + writeReleaseJSON(t, w, map[string]interface{}{ + "releases": []map[string]interface{}{ + {"tag_name": "v1.0.0", "id": "900001", "version_id": 7}, + }, + }) + case "/owner/repo/releases/7.json": + writeReleaseJSON(t, w, map[string]interface{}{ + "tag_name": "v1.0.0", + "attachments": []map[string]interface{}{ + {"id": 12, "title": "a.bin"}, + {"id": "uuid-34", "title": "b.txt"}, + }, + }) + case "/attachments/12", "/attachments/12.json": + fmt.Fprint(w, "content-a") + case "/attachments/uuid-34", "/attachments/uuid-34.json": + fmt.Fprint(w, "content-b") + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + outDir := t.TempDir() + if err := runReleaseShortcut(t, server, "download", map[string]string{ + "id": "v1.0.0", + "output-dir": outDir, + }); err != nil { + t.Fatalf("release download failed: %v", err) + } + for name, want := range map[string]string{"a.bin": "content-a", "b.txt": "content-b"} { + got, err := os.ReadFile(filepath.Join(outDir, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if string(got) != want { + t.Fatalf("%s content = %q, want %q", name, got, want) + } + } +} + +func TestReleaseDownloadNoAttachments(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/owner/repo/releases.json" { + if r.URL.Query().Get("page") != "1" { + writeReleaseJSON(t, w, map[string]interface{}{"releases": []interface{}{}}) + return + } + writeReleaseJSON(t, w, map[string]interface{}{ + "releases": []map[string]interface{}{ + {"tag_name": "v1.0.0", "id": "900001", "version_id": 7}, + }, + }) + return + } + writeReleaseJSON(t, w, map[string]interface{}{"tag_name": "v1.0.0"}) + }) + defer server.Close() + + if err := runReleaseShortcut(t, server, "download", map[string]string{"id": "v1.0.0"}); err == nil { + t.Fatal("expected error when release has no attachments") + } +} From d08c80079fbe5338ad188182a16efee9e9607650 Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 16:40:05 +0000 Subject: [PATCH 10/11] =?UTF-8?q?fix(release):=20+view=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20tag/gitea=20id/version=5Fid=20=E4=B8=89=E9=87=8D?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E8=A7=A3=E6=9E=90=EF=BC=88=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E4=B8=8A=E6=8C=89=20tag=20=E8=AF=B7=E6=B1=82=E5=8E=9F=E6=9C=AC?= =?UTF-8?q?=E5=9B=9E=E8=90=BD=20HTML=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/release/release.go | 6 +++++- shortcuts/release/release_test.go | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 891ca3c..79e1c32 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -173,7 +173,11 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + versionID, err := resolveVersionID(ctx, id) + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), versionID), nil) if err != nil { return err } diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go index 66ad35e..1791ea7 100644 --- a/shortcuts/release/release_test.go +++ b/shortcuts/release/release_test.go @@ -90,8 +90,18 @@ func TestReleaseEdit(t *testing.T) { func TestReleaseView(t *testing.T) { server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { - assertReleaseRequest(t, r, "GET", "/owner/repo/releases/v1.0.json") - writeReleaseJSON(t, w, map[string]interface{}{"tag_name": "v1.0", "name": "Version 1.0"}) + switch r.URL.Path { + case "/owner/repo/releases.json": + writeReleaseJSON(t, w, map[string]interface{}{ + "releases": []map[string]interface{}{ + {"tag_name": "v1.0", "id": "900001", "version_id": 7}, + }, + }) + case "/owner/repo/releases/7.json": + writeReleaseJSON(t, w, map[string]interface{}{"tag_name": "v1.0", "name": "Version 1.0"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } }) defer server.Close() From cbc99b06fe42d886b0c88f30f048d5885e57e4bd Mon Sep 17 00:00:00 2001 From: Taoyouce Date: Tue, 7 Jul 2026 16:42:44 +0000 Subject: [PATCH 11/11] =?UTF-8?q?feat(release):=20+edit/+update/+delete=20?= =?UTF-8?q?=E5=90=8C=E6=A0=B7=E6=94=AF=E6=8C=81=20tag/gitea=20id/version?= =?UTF-8?q?=5Fid=20=E4=B8=89=E9=87=8D=E5=BC=95=E7=94=A8=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/release/release.go | 16 +++++++++-- shortcuts/release/release_test.go | 45 +++++++++++++++++++++---------- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 79e1c32..4380b36 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -142,7 +142,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "edit", Description: "Get release edit data", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Release version ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -152,6 +152,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } + id, err = resolveVersionID(ctx, id) + if err != nil { + return err + } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s/edit", ctx.RepoPath(), id), nil) if err != nil { return err @@ -261,7 +265,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Name: "delete", Description: tr.T("cmd.release.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: tr.T("flag.release.id"), Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, {Name: "dry-run", Usage: "Preview the delete request without changing release state", Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { @@ -272,6 +276,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } + id, err = resolveVersionID(ctx, id) + if err != nil { + return err + } path := fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id) if ctx.Arg("dry-run") == "true" { return ctx.OutputData(map[string]interface{}{ @@ -325,6 +333,10 @@ func runUpdate(ctx *common.RuntimeContext) error { if err := validateReleaseUpdateArgs(ctx); err != nil { return err } + id, err = resolveVersionID(ctx, id) + if err != nil { + return err + } current, err := fetchReleaseEdit(ctx, id) if err != nil { return fmt.Errorf("fetch release edit data: %w", err) diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go index 1791ea7..1f49bed 100644 --- a/shortcuts/release/release_test.go +++ b/shortcuts/release/release_test.go @@ -77,10 +77,10 @@ func TestReleaseCreateWithBody(t *testing.T) { } func TestReleaseEdit(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { assertReleaseRequest(t, r, "GET", "/owner/repo/releases/7/edit.json") writeReleaseJSON(t, w, releaseEditFixture()) - }) + })) defer server.Close() if err := runReleaseShortcut(t, server, "edit", map[string]string{"id": "7"}); err != nil { @@ -112,7 +112,7 @@ func TestReleaseView(t *testing.T) { func TestReleaseUpdatePreservesExistingFields(t *testing.T) { var payload map[string]interface{} - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/7/edit.json": writeReleaseJSON(t, w, releaseEditFixture()) @@ -122,7 +122,7 @@ func TestReleaseUpdatePreservesExistingFields(t *testing.T) { default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - }) + })) defer server.Close() err := runReleaseShortcut(t, server, "update", map[string]string{ @@ -145,7 +145,7 @@ func TestReleaseUpdatePreservesExistingFields(t *testing.T) { func TestReleaseUpdateOverridesAttachmentIDs(t *testing.T) { var payload map[string]interface{} - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/7/edit.json": writeReleaseJSON(t, w, releaseEditFixture()) @@ -155,7 +155,7 @@ func TestReleaseUpdateOverridesAttachmentIDs(t *testing.T) { default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - }) + })) defer server.Close() err := runReleaseShortcut(t, server, "update", map[string]string{ @@ -172,13 +172,13 @@ func TestReleaseUpdateOverridesAttachmentIDs(t *testing.T) { } func TestReleaseUpdateDryRunDoesNotWrite(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { if r.Method == "PUT" { t.Fatalf("dry-run should not update release, got %s %s", r.Method, r.URL.Path) } assertReleaseRequest(t, r, "GET", "/owner/repo/releases/7/edit.json") writeReleaseJSON(t, w, releaseEditFixture()) - }) + })) defer server.Close() err := runReleaseShortcut(t, server, "update", map[string]string{ @@ -192,10 +192,10 @@ func TestReleaseUpdateDryRunDoesNotWrite(t *testing.T) { } func TestReleaseDeleteSuccess(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { assertReleaseRequest(t, r, "DELETE", "/owner/repo/releases/1.json") writeReleaseJSON(t, w, map[string]interface{}{"message": "deleted"}) - }) + })) defer server.Close() if err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"}); err != nil { @@ -204,9 +204,9 @@ func TestReleaseDeleteSuccess(t *testing.T) { } func TestReleaseDeleteDryRunDoesNotCallAPI(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { t.Fatalf("delete dry-run should not call API, got %s %s", r.Method, r.URL.Path) - }) + })) defer server.Close() err := runReleaseShortcut(t, server, "delete", map[string]string{ @@ -219,7 +219,7 @@ func TestReleaseDeleteDryRunDoesNotCallAPI(t *testing.T) { } func TestReleaseDeleteBugWorkaround(t *testing.T) { - server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + server := newReleaseTestServer(t, withVersionResolution(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "DELETE": w.WriteHeader(http.StatusInternalServerError) @@ -230,7 +230,7 @@ func TestReleaseDeleteBugWorkaround(t *testing.T) { default: t.Fatalf("unexpected method: %s", r.Method) } - }) + })) defer server.Close() if err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"}); err != nil { @@ -593,3 +593,20 @@ func TestReleaseDownloadNoAttachments(t *testing.T) { t.Fatal("expected error when release has no attachments") } } + +// withVersionResolution serves the release list endpoint that +// resolveVersionID pages through, then delegates everything else. +func withVersionResolution(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json" { + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("page") != "1" { + fmt.Fprint(w, `{"releases":[]}`) + return + } + fmt.Fprint(w, `{"releases":[{"tag_name":"v1.0.0","id":"900001","version_id":7},{"tag_name":"v0.9.0","id":"900002","version_id":1}]}`) + return + } + h(w, r) + } +}