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