feat(release): +create 新增 --attachment-files 本地文件自动上传并附加

This commit is contained in:
Taoyouce 2026-07-07 16:02:09 +00:00
parent 19209c5cea
commit 78aee0cf5c
4 changed files with 110 additions and 1 deletions

View File

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

View File

@ -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 或标签",

View File

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

View File

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