feat(attachment): 新增 attachment +upload/+download 附件上传下载命令组

This commit is contained in:
Taoyouce 2026-07-07 15:24:46 +00:00
parent c09645da62
commit 560ca9b5f0
8 changed files with 530 additions and 44 deletions

View File

@ -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

View File

@ -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

163
internal/client/upload.go Normal file
View File

@ -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
}

View File

@ -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",

View File

@ -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": "分支名称",

View File

@ -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,
})
},
},
}
}

View File

@ -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")
}
}

View File

@ -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 {