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) + } +}