From 3dfce6f9e766f3bcd60d9c0d28c402afa8b511b3 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Wed, 10 Jun 2026 17:05:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E5=A2=9E=E5=8A=A0=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=B5=84=E4=BA=A7=E7=AE=A1=E7=90=86=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +- README.zh-CN.md | 12 +- doc/changes/release-asset-shortcuts.md | 25 ++ internal/client/client.go | 164 +++++++-- internal/client/client_test.go | 84 +++++ internal/i18n/locales/en-US.json | 4 + internal/i18n/locales/zh-CN.json | 4 + shortcuts/common/types.go | 5 + shortcuts/release/assets.go | 447 +++++++++++++++++++++++++ shortcuts/release/assets_test.go | 216 ++++++++++++ shortcuts/release/release.go | 25 +- shortcuts/release/release_test.go | 6 +- 12 files changed, 956 insertions(+), 48 deletions(-) create mode 100644 doc/changes/release-asset-shortcuts.md create mode 100644 shortcuts/release/assets.go create mode 100644 shortcuts/release/assets_test.go diff --git a/README.md b/README.md index b43dcd1..01fc3b1 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🔀 PR | Create, merge, review pull requests, view changed files | | 👥 Member | List, add, remove repository members, change roles, create and accept invite links | | 🌿 Branch | Create, delete, list, protect, unprotect branches | -| 🏷️ Release | Create, edit, update, view, delete releases | +| 🏷️ Release | Create, edit, update, view, delete releases, and manage release assets | | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | | ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | @@ -424,6 +424,16 @@ gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0. # View a release gitlink-cli release +view --owner Gitlink --repo forgeplus -i +# List assets attached to a release +gitlink-cli release +assets --owner Gitlink --repo forgeplus -i + +# Attach or detach existing asset IDs while preserving release metadata +gitlink-cli release +attach --owner Gitlink --repo forgeplus -i --attachment-ids 12,34 --dry-run +gitlink-cli release +detach --owner Gitlink --repo forgeplus -i --attachment-ids 34 --dry-run + +# Upload a local file and attach it to the release in one step +gitlink-cli release +upload --owner Gitlink --repo forgeplus -i --file dist/gitlink-cli_linux_amd64.tar.gz --asset-name gitlink-cli-linux-amd64.tar.gz --description "Linux binary" --dry-run + # Get edit data and update while preserving unspecified fields gitlink-cli release +edit --owner Gitlink --repo forgeplus -i gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b "Updated changelog" --dry-run diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..d2a5fdb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -101,7 +101,7 @@ | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | | 🌿 分支 | 创建、删除、保护分支 | -| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release | +| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release,并管理发布资产 | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | @@ -415,6 +415,16 @@ gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0. # 查看 Release gitlink-cli release +view --owner Gitlink --repo forgeplus -i +# 列出 Release 已绑定的资产 +gitlink-cli release +assets --owner Gitlink --repo forgeplus -i + +# 绑定或移除已有附件 ID,同时保留 Release 其他字段 +gitlink-cli release +attach --owner Gitlink --repo forgeplus -i --attachment-ids 12,34 --dry-run +gitlink-cli release +detach --owner Gitlink --repo forgeplus -i --attachment-ids 34 --dry-run + +# 上传本地文件并一步绑定到 Release +gitlink-cli release +upload --owner Gitlink --repo forgeplus -i --file dist/gitlink-cli_linux_amd64.tar.gz --asset-name gitlink-cli-linux-amd64.tar.gz --description "Linux 二进制包" --dry-run + # 获取编辑数据并保留未传字段更新 gitlink-cli release +edit --owner Gitlink --repo forgeplus -i gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b "更新后的内容" --dry-run diff --git a/doc/changes/release-asset-shortcuts.md b/doc/changes/release-asset-shortcuts.md new file mode 100644 index 0000000..f632bcf --- /dev/null +++ b/doc/changes/release-asset-shortcuts.md @@ -0,0 +1,25 @@ +# Release Asset Shortcuts + +Submitter: Mengz + +This change fills the missing release asset workflow in `gitlink-cli`, so users can upload files, attach existing assets, inspect attached assets, and detach assets without dropping the rest of the release metadata. + +## Commands + +- Add `release +assets` to inspect the assets currently attached to a release. +- Add `release +attach` to merge existing attachment IDs into a release. +- Add `release +detach` to remove attachment IDs from a release. +- Add `release +upload` to upload a local file and attach it to a release in one command. + +## Behavior + +- Add multipart upload support to `internal/client` and expose it through `shortcuts/common.RuntimeContext`. +- Keep the `RuntimeContext` encapsulation instead of calling the low-level client directly from shortcuts. +- Preserve existing release fields when attaching, detaching, or uploading assets, so these commands do not accidentally overwrite `name`, `tag_name`, `body`, `target_commitish`, `draft`, or `prerelease`. +- Accept release tags for asset operations by resolving them to the internal release version ID before write operations. +- Clean up the uploaded attachment automatically if the follow-up release update fails, avoiding orphaned assets. + +## Verification + +- Add multipart client tests covering field values, uploaded filename, content type, and file body. +- Add release shortcut tests covering asset listing, attach/detach dry-run behavior, attachment merging, upload-and-attach flow, and cleanup after failed release update. diff --git a/internal/client/client.go b/internal/client/client.go index 1d20aed..3dbb05f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -5,8 +5,11 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "net/url" + "sort" "strings" "github.com/gitlink-org/gitlink-cli/internal/auth" @@ -26,6 +29,13 @@ type APIError struct { Message string } +type MultipartFile struct { + FieldName string + FileName string + ContentType string + Reader io.Reader +} + func (e *APIError) Error() string { return fmt.Sprintf("[%v] %s", e.Code, e.Message) } @@ -42,45 +52,57 @@ func New() (*Client, error) { } func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { - path = normalizeAPIPath(c.BaseURL, path) - - // Append .json suffix if not already present (GitLink API convention) - // Handle paths that may already contain query strings (e.g., /path?key=val) - if idx := strings.Index(path, "?"); idx != -1 { - basePath := path[:idx] - queryStr := path[idx:] - if shouldAppendJSONSuffix(basePath) { - path = basePath + ".json" + queryStr - } - } else if shouldAppendJSONSuffix(path) { - path += ".json" - } - fullURL := c.BaseURL + path - if len(query) > 0 { - sep := "?" - if strings.Contains(fullURL, "?") { - sep = "&" - } - fullURL += sep + query.Encode() - } - - // Replace path params var bodyReader io.Reader + contentType := "" if body != nil { data, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(data) + contentType = "application/json" } - req, err := http.NewRequest(method, fullURL, bodyReader) + return c.doRequest(method, path, bodyReader, contentType, query) +} + +func (c *Client) PostMultipart(path string, fields map[string]string, files []MultipartFile) (*output.Envelope, error) { + if len(files) == 0 { + return nil, fmt.Errorf("at least one multipart file is required") + } + + reader, writer := io.Pipe() + form := multipart.NewWriter(writer) + + go func() { + writeErr := writeMultipartBody(form, fields, files) + closeErr := form.Close() + if writeErr == nil { + writeErr = closeErr + } + if writeErr != nil { + _ = writer.CloseWithError(writeErr) + return + } + _ = writer.Close() + }() + + return c.doRequest(http.MethodPost, path, reader, form.FormDataContentType(), nil) +} + +func (c *Client) doRequest(method, path string, body io.Reader, contentType string, query url.Values) (*output.Envelope, error) { + fullURL := c.apiURL(path, query) + + req, err := http.NewRequest(method, fullURL, body) if err != nil { return nil, err } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } if c.Debug { - fmt.Printf("→ %s %s\n", method, fullURL) + fmt.Printf("-> %s %s\n", method, fullURL) } resp, err := c.HTTP.Do(req) @@ -95,15 +117,97 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } if c.Debug { - fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) + previewLen := len(respData) + if previewLen > 200 { + previewLen = 200 + } + fmt.Printf("<- %d %s\n", resp.StatusCode, string(respData[:previewLen])) } + return parseResponseEnvelope(resp.StatusCode, respData) +} + +func writeMultipartBody(form *multipart.Writer, fields map[string]string, files []MultipartFile) error { + fieldNames := make([]string, 0, len(fields)) + for name := range fields { + fieldNames = append(fieldNames, name) + } + sort.Strings(fieldNames) + + for _, name := range fieldNames { + if err := form.WriteField(name, fields[name]); err != nil { + return err + } + } + + for _, file := range files { + if file.Reader == nil { + return fmt.Errorf("multipart file reader is required") + } + part, err := createMultipartPart(form, file) + if err != nil { + return err + } + if _, err := io.Copy(part, file.Reader); err != nil { + return err + } + } + + return nil +} + +func createMultipartPart(form *multipart.Writer, file MultipartFile) (io.Writer, error) { + fieldName := strings.TrimSpace(file.FieldName) + if fieldName == "" { + fieldName = "file" + } + fileName := strings.TrimSpace(file.FileName) + if fileName == "" { + fileName = fieldName + } + if strings.TrimSpace(file.ContentType) == "" { + return form.CreateFormFile(fieldName, fileName) + } + + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, fieldName, fileName)) + header.Set("Content-Type", file.ContentType) + return form.CreatePart(header) +} + +func (c *Client) apiURL(path string, query url.Values) string { + path = normalizeAPIPath(c.BaseURL, path) + + // Append .json suffix if not already present (GitLink API convention) + // Handle paths that may already contain query strings (e.g., /path?key=val) + if idx := strings.Index(path, "?"); idx != -1 { + basePath := path[:idx] + queryStr := path[idx:] + if shouldAppendJSONSuffix(basePath) { + path = basePath + ".json" + queryStr + } + } else if shouldAppendJSONSuffix(path) { + path += ".json" + } + + fullURL := c.BaseURL + path + if len(query) > 0 { + sep := "?" + if strings.Contains(fullURL, "?") { + sep = "&" + } + fullURL += sep + query.Encode() + } + return fullURL +} + +func parseResponseEnvelope(statusCode int, respData []byte) (*output.Envelope, error) { // Check HTTP-level errors - if resp.StatusCode >= 400 { + if statusCode >= 400 { return nil, &APIError{ - StatusCode: resp.StatusCode, - Code: resp.StatusCode, - Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), + StatusCode: statusCode, + Code: statusCode, + Message: fmt.Sprintf("HTTP %d: %s", statusCode, strings.TrimSpace(string(respData))), } } diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 9a55bcb..709d728 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -2,11 +2,14 @@ package client import ( "encoding/json" + "io" + "mime" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "strings" "testing" ) @@ -273,6 +276,87 @@ func TestClientDoWithBody(t *testing.T) { } } +func TestClientPostMultipart(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/attachments.json" { + t.Fatalf("expected path /api/attachments.json, got %s", r.URL.Path) + } + + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + t.Fatalf("parse media type: %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("Content-Type = %q, want multipart/form-data", mediaType) + } + + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + if got := r.FormValue("description"); got != "release asset" { + t.Fatalf("description = %q, want %q", got, "release asset") + } + + files := r.MultipartForm.File["file"] + if len(files) != 1 { + t.Fatalf("expected 1 uploaded file, got %d", len(files)) + } + if files[0].Filename != "asset.zip" { + t.Fatalf("filename = %q, want %q", files[0].Filename, "asset.zip") + } + if got := files[0].Header.Get("Content-Type"); got != "application/octet-stream" { + t.Fatalf("part Content-Type = %q, want %q", got, "application/octet-stream") + } + + file, err := files[0].Open() + if err != nil { + t.Fatalf("open multipart file: %v", err) + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + t.Fatalf("read multipart file: %v", err) + } + if string(data) != "binary content" { + t.Fatalf("file body = %q, want %q", string(data), "binary content") + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id":"asset-1"}`)) + })) + defer server.Close() + + c := &Client{HTTP: server.Client(), BaseURL: server.URL} + env, err := c.PostMultipart("/api/attachments", map[string]string{ + "description": "release asset", + }, []MultipartFile{ + { + FieldName: "file", + FileName: "asset.zip", + ContentType: "application/octet-stream", + Reader: strings.NewReader("binary content"), + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !env.OK { + t.Fatal("expected OK=true") + } +} + +func TestClientPostMultipartRequiresFile(t *testing.T) { + c := &Client{HTTP: &http.Client{}, BaseURL: "https://gitlink.example.com"} + _, err := c.PostMultipart("/attachments", nil, nil) + if err == nil { + t.Fatal("expected error when no multipart file is provided") + } +} + func TestClientGet(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index c8df7cf..60ddd60 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -55,9 +55,13 @@ "cmd.pr.versions.short": "List pull request patchset versions", "cmd.pr.view.short": "View pull request details", "cmd.release.create.short": "Create a release", + "cmd.release.assets.short": "List release assets", + "cmd.release.attach.short": "Attach assets to a release", "cmd.release.delete.short": "Delete a release", + "cmd.release.detach.short": "Detach assets from a release", "cmd.release.list.short": "List releases", "cmd.release.short": "Release operations", + "cmd.release.upload.short": "Upload an asset and attach it to a release", "cmd.release.view.short": "View release details", "cmd.repo.create.short": "Create a new repository", "cmd.repo.delete.short": "Delete a repository", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 7fdc2b1..cce8f61 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -55,9 +55,13 @@ "cmd.pr.versions.short": "列出拉取请求补丁集版本", "cmd.pr.view.short": "查看拉取请求详情", "cmd.release.create.short": "创建发布", + "cmd.release.assets.short": "列出发布资产", + "cmd.release.attach.short": "为发布绑定资产", "cmd.release.delete.short": "删除发布", + "cmd.release.detach.short": "从发布移除资产", "cmd.release.list.short": "列出发布", "cmd.release.short": "发布操作", + "cmd.release.upload.short": "上传文件并绑定到发布", "cmd.release.view.short": "查看发布详情", "cmd.repo.create.short": "创建新仓库", "cmd.repo.delete.short": "删除仓库", diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 87a052a..100e467 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -90,6 +90,11 @@ func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Value return ctx.Client.Do(method, path, nil, query) } +// PostMultipart uploads multipart/form-data through the shared runtime client. +func (ctx *RuntimeContext) PostMultipart(path string, fields map[string]string, files []client.MultipartFile) (*output.Envelope, error) { + return ctx.Client.PostMultipart(path, fields, files) +} + // PaginateAll fetches all pages. func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) { return ctx.Client.PaginateAll(path, params) diff --git a/shortcuts/release/assets.go b/shortcuts/release/assets.go new file mode 100644 index 0000000..e6474fe --- /dev/null +++ b/shortcuts/release/assets.go @@ -0,0 +1,447 @@ +package release + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type releaseTarget struct { + Identifier string + VersionID string + View map[string]interface{} + Edit map[string]interface{} +} + +func releaseAssetShortcuts(tr *i18n.Translator) []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "assets", + Description: tr.T("cmd.release.assets.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, + }, + Run: runAssets, + }, + { + Name: "attach", + Description: tr.T("cmd.release.attach.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, + {Name: "attachment-ids", Usage: "Comma-separated attachment IDs", Required: true}, + {Name: "dry-run", Usage: "Preview the attach request without changing release state", Bool: true, Default: "false"}, + }, + Run: runAttach, + }, + { + Name: "detach", + Description: tr.T("cmd.release.detach.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, + {Name: "attachment-ids", Usage: "Comma-separated attachment IDs", Required: true}, + {Name: "dry-run", Usage: "Preview the detach request without changing release state", Bool: true, Default: "false"}, + }, + Run: runDetach, + }, + { + Name: "upload", + Description: tr.T("cmd.release.upload.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, + {Name: "file", Usage: "Local file path", Required: true}, + {Name: "asset-name", Usage: "Override the uploaded asset filename"}, + {Name: "description", Usage: "Attachment description"}, + {Name: "dry-run", Usage: "Preview the upload request without changing release state", Bool: true, Default: "false"}, + }, + Run: runUpload, + }, + } +} + +func runAssets(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + identifier, err := ctx.RequireArg("id") + if err != nil { + return err + } + + view, err := fetchReleaseView(ctx, identifier) + if err != nil { + return err + } + target := &releaseTarget{ + Identifier: identifier, + VersionID: releaseVersionID(identifier, view), + View: view, + } + + result := releaseActionResult(ctx, target, "list_release_assets") + result["attachment_ids"] = releaseAttachmentIDs(view) + result["attachments"] = releaseAttachments(view) + return ctx.OutputData(result) +} + +func runAttach(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + identifier, err := ctx.RequireArg("id") + if err != nil { + return err + } + requestedIDs, err := parseReleaseAttachmentIDs(ctx.Arg("attachment-ids")) + if err != nil { + return err + } + + target, err := resolveReleaseTarget(ctx, identifier) + if err != nil { + return err + } + + currentIDs := releaseAttachmentIDs(target.Edit) + nextIDs, addedIDs := mergeReleaseAttachmentIDs(currentIDs, requestedIDs) + result := releaseActionResult(ctx, target, "attach_release_assets") + result["dry_run"] = ctx.Arg("dry-run") == "true" + result["changed"] = len(addedIDs) > 0 + result["current_attachment_ids"] = currentIDs + result["requested_attachment_ids"] = requestedIDs + result["added_attachment_ids"] = addedIDs + result["attachment_ids"] = nextIDs + if len(addedIDs) == 0 || ctx.Arg("dry-run") == "true" { + return ctx.OutputData(result) + } + + env, err := updateReleaseAttachments(ctx, target.VersionID, target.Edit, nextIDs) + if err != nil { + return err + } + result["release"] = env.Data + return ctx.OutputData(result) +} + +func runDetach(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + identifier, err := ctx.RequireArg("id") + if err != nil { + return err + } + requestedIDs, err := parseReleaseAttachmentIDs(ctx.Arg("attachment-ids")) + if err != nil { + return err + } + + target, err := resolveReleaseTarget(ctx, identifier) + if err != nil { + return err + } + + currentIDs := releaseAttachmentIDs(target.Edit) + nextIDs, removedIDs := removeReleaseAttachmentIDs(currentIDs, requestedIDs) + result := releaseActionResult(ctx, target, "detach_release_assets") + result["dry_run"] = ctx.Arg("dry-run") == "true" + result["changed"] = len(removedIDs) > 0 + result["current_attachment_ids"] = currentIDs + result["requested_attachment_ids"] = requestedIDs + result["removed_attachment_ids"] = removedIDs + result["attachment_ids"] = nextIDs + if len(removedIDs) == 0 || ctx.Arg("dry-run") == "true" { + return ctx.OutputData(result) + } + + env, err := updateReleaseAttachments(ctx, target.VersionID, target.Edit, nextIDs) + if err != nil { + return err + } + result["release"] = env.Data + return ctx.OutputData(result) +} + +func runUpload(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + identifier, err := ctx.RequireArg("id") + if err != nil { + return err + } + filePath, uploadName, size, err := resolveUploadFile(ctx.Arg("file"), ctx.Arg("asset-name")) + if err != nil { + return err + } + + target, err := resolveReleaseTarget(ctx, identifier) + if err != nil { + return err + } + + result := releaseActionResult(ctx, target, "upload_release_asset") + result["dry_run"] = ctx.Arg("dry-run") == "true" + result["current_attachment_ids"] = releaseAttachmentIDs(target.Edit) + result["file"] = map[string]interface{}{ + "path": filePath, + "asset_name": uploadName, + "size_bytes": size, + "description": strings.TrimSpace(ctx.Arg("description")), + } + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(result) + } + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("open asset file: %w", err) + } + defer file.Close() + + fields := map[string]string{} + if description := strings.TrimSpace(ctx.Arg("description")); description != "" { + fields["description"] = description + } + uploadEnv, err := ctx.PostMultipart("/attachments", fields, []client.MultipartFile{ + { + FieldName: "file", + FileName: uploadName, + Reader: file, + }, + }) + if err != nil { + return fmt.Errorf("upload asset: %w", err) + } + + attachment, err := releaseMap(uploadEnv.Data, "attachment upload response") + if err != nil { + return err + } + attachmentID := releaseIDString(attachment["id"]) + if attachmentID == "" { + return fmt.Errorf("attachment upload response did not include an attachment ID") + } + + nextIDs, _ := mergeReleaseAttachmentIDs(releaseAttachmentIDs(target.Edit), []string{attachmentID}) + releaseEnv, err := updateReleaseAttachments(ctx, target.VersionID, target.Edit, nextIDs) + if err != nil { + if cleanupErr := deleteAttachment(ctx, attachmentID); cleanupErr != nil { + return fmt.Errorf("attach uploaded asset to release: %w (cleanup failed: %v)", err, cleanupErr) + } + return fmt.Errorf("attach uploaded asset to release: %w", err) + } + + result["attachment_ids"] = nextIDs + result["uploaded_attachment_id"] = attachmentID + result["attachment"] = attachment + result["release"] = releaseEnv.Data + return ctx.OutputData(result) +} + +func resolveReleaseTarget(ctx *common.RuntimeContext, identifier string) (*releaseTarget, error) { + view, err := fetchReleaseView(ctx, identifier) + if err != nil { + return nil, fmt.Errorf("fetch release: %w", err) + } + + versionID := releaseVersionID(identifier, view) + if versionID == "" { + return nil, fmt.Errorf("failed to resolve release version ID from %q", identifier) + } + + edit, err := fetchReleaseEdit(ctx, versionID) + if err != nil { + return nil, fmt.Errorf("fetch release edit data: %w", err) + } + + return &releaseTarget{ + Identifier: identifier, + VersionID: versionID, + View: view, + Edit: edit, + }, nil +} + +func fetchReleaseView(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) { + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + if err != nil { + return nil, err + } + return releaseMap(env.Data, "release data") +} + +func updateReleaseAttachments(ctx *common.RuntimeContext, versionID string, current map[string]interface{}, attachmentIDs []string) (*output.Envelope, error) { + payload, err := releaseCurrentPayload(current, attachmentIDs) + if err != nil { + return nil, err + } + return ctx.CallAPI("PUT", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), versionID), payload) +} + +func releaseActionResult(ctx *common.RuntimeContext, target *releaseTarget, action string) map[string]interface{} { + return map[string]interface{}{ + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "release_ref": target.Identifier, + "release_id": target.VersionID, + "tag_name": releaseString(target.View, "tag_name"), + "release_name": firstReleaseValue(releaseString(target.View, "name"), releaseString(target.Edit, "name")), + "action": action, + } +} + +func releaseVersionID(identifier string, view map[string]interface{}) string { + if id := releaseIDString(view["version_id"]); id != "" { + return id + } + if isNumericReleaseID(identifier) { + return strings.TrimSpace(identifier) + } + return "" +} + +func isNumericReleaseID(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func releaseMap(data interface{}, name string) (map[string]interface{}, error) { + result, ok := data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to parse %s", name) + } + return result, nil +} + +func releaseCurrentPayload(current map[string]interface{}, attachmentIDs []string) (map[string]interface{}, error) { + name := releaseString(current, "name") + if name == "" { + return nil, fmt.Errorf("required release name is missing in remote data") + } + tag := releaseString(current, "tag_name") + if tag == "" { + return nil, fmt.Errorf("required release tag is missing in remote data") + } + + ids := make([]string, len(attachmentIDs)) + copy(ids, attachmentIDs) + return map[string]interface{}{ + "name": name, + "tag_name": tag, + "body": releaseString(current, "body"), + "target_commitish": releaseString(current, "target_commitish"), + "draft": releaseBoolValue(current, "draft", false), + "prerelease": releaseBoolValue(current, "prerelease", false), + "attachment_ids": ids, + }, nil +} + +func releaseAttachments(values map[string]interface{}) []map[string]interface{} { + if values == nil { + return nil + } + + switch attachments := values["attachments"].(type) { + case []interface{}: + items := make([]map[string]interface{}, 0, len(attachments)) + for _, attachment := range attachments { + item, ok := attachment.(map[string]interface{}) + if !ok { + continue + } + items = append(items, item) + } + return items + case []map[string]interface{}: + return attachments + default: + return nil + } +} + +func mergeReleaseAttachmentIDs(currentIDs, requestedIDs []string) ([]string, []string) { + merged := make([]string, 0, len(currentIDs)+len(requestedIDs)) + seen := map[string]bool{} + + for _, id := range currentIDs { + if seen[id] { + continue + } + seen[id] = true + merged = append(merged, id) + } + + added := make([]string, 0, len(requestedIDs)) + for _, id := range requestedIDs { + if seen[id] { + continue + } + seen[id] = true + merged = append(merged, id) + added = append(added, id) + } + + return merged, added +} + +func removeReleaseAttachmentIDs(currentIDs, requestedIDs []string) ([]string, []string) { + removeSet := map[string]bool{} + for _, id := range requestedIDs { + removeSet[id] = true + } + + remaining := make([]string, 0, len(currentIDs)) + removed := make([]string, 0, len(requestedIDs)) + for _, id := range currentIDs { + if removeSet[id] { + removed = append(removed, id) + continue + } + remaining = append(remaining, id) + } + + return remaining, removed +} + +func resolveUploadFile(path, assetName string) (string, string, int64, error) { + cleanPath := filepath.Clean(strings.TrimSpace(path)) + if cleanPath == "." || cleanPath == "" { + return "", "", 0, fmt.Errorf("--file is required") + } + + info, err := os.Stat(cleanPath) + if err != nil { + return "", "", 0, fmt.Errorf("stat asset file: %w", err) + } + if info.IsDir() { + return "", "", 0, fmt.Errorf("--file must point to a file, got directory %q", cleanPath) + } + + uploadName := strings.TrimSpace(assetName) + if uploadName == "" { + uploadName = filepath.Base(cleanPath) + } + if strings.ContainsAny(uploadName, `/\`) { + return "", "", 0, fmt.Errorf("--asset-name must be a filename, got %q", uploadName) + } + + return cleanPath, uploadName, info.Size(), nil +} + +func deleteAttachment(ctx *common.RuntimeContext, attachmentID string) error { + _, err := ctx.CallAPI("DELETE", fmt.Sprintf("/attachments/%s", attachmentID), nil) + return err +} diff --git a/shortcuts/release/assets_test.go b/shortcuts/release/assets_test.go new file mode 100644 index 0000000..b294abe --- /dev/null +++ b/shortcuts/release/assets_test.go @@ -0,0 +1,216 @@ +package release + +import ( + "io" + "net/http" + "os" + "path/filepath" + "testing" +) + +func TestReleaseAssets(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, releaseViewFixture("7")) + }) + defer server.Close() + + if err := runReleaseShortcut(t, server, "assets", map[string]string{"id": "v1.0"}); err != nil { + t.Fatalf("assets shortcut failed: %v", err) + } +} + +func TestReleaseAttachDryRunDoesNotWrite(t *testing.T) { + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/v1.0.json": + writeReleaseJSON(t, w, releaseViewFixture("7")) + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/7/edit.json": + writeReleaseJSON(t, w, releaseEditFixture()) + default: + t.Fatalf("dry-run should not write, got %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "attach", map[string]string{ + "id": "v1.0", + "attachment-ids": "34,56", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("attach dry-run failed: %v", err) + } +} + +func TestReleaseAttachWritesMergedAttachmentIDs(t *testing.T) { + var payload map[string]interface{} + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/v1.0.json": + writeReleaseJSON(t, w, releaseViewFixture("7")) + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/7/edit.json": + writeReleaseJSON(t, w, releaseEditFixture()) + case r.Method == http.MethodPut && r.URL.Path == "/owner/repo/releases/7.json": + payload = decodeReleaseJSON(t, r) + writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "attach", map[string]string{ + "id": "v1.0", + "attachment-ids": "34,56", + }) + if err != nil { + t.Fatalf("attach shortcut failed: %v", err) + } + + assertReleaseEqual(t, payload["name"], "Old release") + assertReleaseEqual(t, payload["tag_name"], "v1.0.0") + assertReleaseStringSlice(t, payload["attachment_ids"], []string{"12", "34", "56"}) +} + +func TestReleaseDetachWritesRemainingAttachmentIDs(t *testing.T) { + var payload map[string]interface{} + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/v1.0.json": + writeReleaseJSON(t, w, releaseViewFixture("7")) + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/7/edit.json": + writeReleaseJSON(t, w, releaseEditFixture()) + case r.Method == http.MethodPut && r.URL.Path == "/owner/repo/releases/7.json": + payload = decodeReleaseJSON(t, r) + writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "detach", map[string]string{ + "id": "v1.0", + "attachment-ids": "12", + }) + if err != nil { + t.Fatalf("detach shortcut failed: %v", err) + } + + assertReleaseStringSlice(t, payload["attachment_ids"], []string{"34"}) +} + +func TestReleaseUploadUploadsAndAttachesAsset(t *testing.T) { + var payload map[string]interface{} + path := filepath.Join(t.TempDir(), "artifact.zip") + if err := os.WriteFile(path, []byte("artifact-bytes"), 0600); err != nil { + t.Fatalf("write test asset: %v", err) + } + + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/v1.0.json": + writeReleaseJSON(t, w, releaseViewFixture("7")) + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/7/edit.json": + writeReleaseJSON(t, w, releaseEditFixture()) + case r.Method == http.MethodPost && r.URL.Path == "/attachments.json": + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Fatalf("ParseMultipartForm: %v", err) + } + if got := r.FormValue("description"); got != "CLI binary" { + t.Fatalf("description = %q, want %q", got, "CLI binary") + } + files := r.MultipartForm.File["file"] + if len(files) != 1 { + t.Fatalf("expected 1 uploaded file, got %d", len(files)) + } + if files[0].Filename != "gitlink-cli.zip" { + t.Fatalf("filename = %q, want %q", files[0].Filename, "gitlink-cli.zip") + } + file, err := files[0].Open() + if err != nil { + t.Fatalf("open multipart file: %v", err) + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + t.Fatalf("read multipart file: %v", err) + } + if string(data) != "artifact-bytes" { + t.Fatalf("uploaded body = %q, want %q", string(data), "artifact-bytes") + } + writeReleaseJSON(t, w, map[string]interface{}{"id": "asset-99", "title": "gitlink-cli.zip"}) + case r.Method == http.MethodPut && r.URL.Path == "/owner/repo/releases/7.json": + payload = decodeReleaseJSON(t, r) + writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "updated"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "upload", map[string]string{ + "id": "v1.0", + "file": path, + "asset-name": "gitlink-cli.zip", + "description": "CLI binary", + }) + if err != nil { + t.Fatalf("upload shortcut failed: %v", err) + } + + assertReleaseStringSlice(t, payload["attachment_ids"], []string{"12", "34", "asset-99"}) +} + +func TestReleaseUploadCleansUpOnAttachFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "artifact.zip") + if err := os.WriteFile(path, []byte("artifact-bytes"), 0600); err != nil { + t.Fatalf("write test asset: %v", err) + } + + cleanupCalled := false + server := newReleaseTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/v1.0.json": + writeReleaseJSON(t, w, releaseViewFixture("7")) + case r.Method == http.MethodGet && r.URL.Path == "/owner/repo/releases/7/edit.json": + writeReleaseJSON(t, w, releaseEditFixture()) + case r.Method == http.MethodPost && r.URL.Path == "/attachments.json": + writeReleaseJSON(t, w, map[string]interface{}{"id": "asset-99", "title": "artifact.zip"}) + case r.Method == http.MethodPut && r.URL.Path == "/owner/repo/releases/7.json": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("update failed")) + case r.Method == http.MethodDelete && r.URL.Path == "/attachments/asset-99.json": + cleanupCalled = true + writeReleaseJSON(t, w, map[string]interface{}{"status": 0, "message": "deleted"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runReleaseShortcut(t, server, "upload", map[string]string{ + "id": "v1.0", + "file": path, + }) + if err == nil { + t.Fatal("expected upload shortcut to fail when release update fails") + } + if !cleanupCalled { + t.Fatal("expected uploaded attachment cleanup to run after release update failure") + } +} + +func releaseViewFixture(versionID string) map[string]interface{} { + return map[string]interface{}{ + "version_id": versionID, + "id": "release-gid", + "tag_name": "v1.0.0", + "name": "Old release", + "attachments": []map[string]interface{}{ + {"id": 12, "title": "a.zip"}, + {"id": "34", "title": "b.zip"}, + }, + } +} diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 21d7ec4..5fe6f5a 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -13,7 +13,7 @@ import ( func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) - return []*common.Shortcut{ + shortcuts := []*common.Shortcut{ { Name: "list", Description: tr.T("cmd.release.list.short"), @@ -196,6 +196,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, } + return append(shortcuts, releaseAssetShortcuts(tr)...) } func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { @@ -342,12 +343,16 @@ func releaseBoolFromArgsOrMap(ctx *common.RuntimeContext, name string, current m if ctx.Arg(name) != "" { return releaseBoolArg(ctx, name, defaultValue) } + return releaseBoolValue(current, name, defaultValue), nil +} + +func releaseBoolValue(current map[string]interface{}, name string, defaultValue bool) bool { if current != nil { if value, ok := current[name].(bool); ok { - return value, nil + return value } } - return defaultValue, nil + return defaultValue } func parseReleaseAttachmentIDs(value string) ([]string, error) { @@ -372,20 +377,10 @@ func parseReleaseAttachmentIDs(value string) ([]string, error) { } func releaseAttachmentIDs(current map[string]interface{}) []string { - if current == nil { - return nil - } - attachments, ok := current["attachments"].([]interface{}) - if !ok { - return nil - } + attachments := releaseAttachments(current) ids := make([]string, 0, len(attachments)) for _, attachment := range attachments { - item, ok := attachment.(map[string]interface{}) - if !ok { - continue - } - if id := releaseIDString(item["id"]); id != "" { + if id := releaseIDString(attachment["id"]); id != "" { ids = append(ids, id) } } diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go index aa31664..0d0ef3b 100644 --- a/shortcuts/release/release_test.go +++ b/shortcuts/release/release_test.go @@ -329,7 +329,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", "update", "delete", "assets", "attach", "detach", "upload"} for _, name := range want { if !got[name] { t.Fatalf("missing shortcut %q in %v", name, got) @@ -452,4 +452,8 @@ func ExampleShortcuts() { // view // update // delete + // assets + // attach + // detach + // upload }