feat(releases): add release attachment upload
Some checks failed
Security Scan / security (push) Successful in 12s
Test / test (push) Has been cancelled

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 00:33:59 +00:00
parent b9962f2412
commit e9faaabc8a
4 changed files with 196 additions and 8 deletions

View file

@ -262,7 +262,7 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er
return data, nil
}
func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[string]string, fieldName, fileName string, content io.Reader, out any) error {
func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[string]string, fields map[string]string, fieldName, fileName string, content io.Reader, out any) error {
target, err := url.Parse(c.baseURL + path)
if err != nil {
return core.E("Client.PostMultipart", "forge: parse url", err)
@ -277,13 +277,20 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile(fieldName, fileName)
if err != nil {
return core.E("Client.PostMultipart", "forge: create multipart form file", err)
for key, value := range fields {
if err := writer.WriteField(key, value); err != nil {
return core.E("Client.PostMultipart", "forge: create multipart form field", err)
}
}
if content != nil {
if _, err := io.Copy(part, content); err != nil {
return core.E("Client.PostMultipart", "forge: write multipart form file", err)
if fieldName != "" {
part, err := writer.CreateFormFile(fieldName, fileName)
if err != nil {
return core.E("Client.PostMultipart", "forge: create multipart form file", err)
}
if content != nil {
if _, err := io.Copy(part, content); err != nil {
return core.E("Client.PostMultipart", "forge: write multipart form file", err)
}
}
}
if err := writer.Close(); err != nil {

View file

@ -273,7 +273,7 @@ func attachmentUploadQuery(opts *AttachmentUploadOptions) map[string]string {
func (s *IssueService) createAttachment(ctx context.Context, path string, opts *AttachmentUploadOptions, filename string, content io.Reader) (*types.Attachment, error) {
var out types.Attachment
if err := s.client.postMultipartJSON(ctx, path, attachmentUploadQuery(opts), "attachment", filename, content, &out); err != nil {
if err := s.client.postMultipartJSON(ctx, path, attachmentUploadQuery(opts), nil, "attachment", filename, content, &out); err != nil {
return nil, err
}
return &out, nil

View file

@ -2,6 +2,7 @@ package forge
import (
"context"
"io"
"iter"
"dappco.re/go/core/forge/types"
@ -17,6 +18,23 @@ type ReleaseService struct {
Resource[types.Release, types.CreateReleaseOption, types.EditReleaseOption]
}
// ReleaseAttachmentUploadOptions controls metadata sent when uploading a release attachment.
type ReleaseAttachmentUploadOptions struct {
Name string
ExternalURL string
}
func releaseAttachmentUploadQuery(opts *ReleaseAttachmentUploadOptions) map[string]string {
if opts == nil || opts.Name == "" {
return nil
}
query := make(map[string]string, 1)
if opts.Name != "" {
query["name"] = opts.Name
}
return query
}
func newReleaseService(c *Client) *ReleaseService {
return &ReleaseService{
Resource: *NewResource[types.Release, types.CreateReleaseOption, types.EditReleaseOption](
@ -47,6 +65,32 @@ func (s *ReleaseService) ListAssets(ctx context.Context, owner, repo string, rel
return ListAll[types.Attachment](ctx, s.client, path, nil)
}
// CreateAttachment uploads a new attachment to a release.
//
// If opts.ExternalURL is set, the upload uses the external_url form field and
// ignores filename/content.
func (s *ReleaseService) CreateAttachment(ctx context.Context, owner, repo string, releaseID int64, opts *ReleaseAttachmentUploadOptions, filename string, content io.Reader) (*types.Attachment, error) {
path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID)))
fields := make(map[string]string, 1)
fieldName := "attachment"
if opts != nil && opts.ExternalURL != "" {
fields["external_url"] = opts.ExternalURL
fieldName = ""
filename = ""
content = nil
}
var out types.Attachment
if err := s.client.postMultipartJSON(ctx, path, releaseAttachmentUploadQuery(opts), fields, fieldName, filename, content, &out); err != nil {
return nil, err
}
return &out, nil
}
// CreateAsset uploads a new asset to a release.
func (s *ReleaseService) CreateAsset(ctx context.Context, owner, repo string, releaseID int64, opts *ReleaseAttachmentUploadOptions, filename string, content io.Reader) (*types.Attachment, error) {
return s.CreateAttachment(ctx, owner, repo, releaseID, opts, filename, content)
}
// IterAssets returns an iterator over all assets for a release.
func (s *ReleaseService) IterAssets(ctx context.Context, owner, repo string, releaseID int64) iter.Seq2[types.Attachment, error] {
path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID)))

View file

@ -1,15 +1,63 @@
package forge
import (
"bytes"
"context"
json "github.com/goccy/go-json"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"dappco.re/go/core/forge/types"
)
func readMultipartReleaseAttachment(t *testing.T, r *http.Request) (map[string]string, string, string) {
t.Helper()
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
t.Fatal(err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("got content-type=%q", mediaType)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
fields := make(map[string]string)
reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
var fileName string
var fileContent string
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatal(err)
}
if part.FormName() == "attachment" {
fileName = part.FileName()
fileContent = string(data)
continue
}
fields[part.FormName()] = string(data)
}
return fields, fileName, fileContent
}
func TestReleaseService_List_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@ -85,3 +133,92 @@ func TestReleaseService_GetByTag_Good(t *testing.T) {
t.Errorf("got id=%d, want 1", release.ID)
}
}
func TestReleaseService_CreateAttachment_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/core/go-forge/releases/1/assets" {
t.Errorf("wrong path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("name"); got != "linux-amd64" {
t.Fatalf("got name=%q", got)
}
fields, filename, content := readMultipartReleaseAttachment(t, r)
if !reflect.DeepEqual(fields, map[string]string{}) {
t.Fatalf("got fields=%#v", fields)
}
if filename != "release.tar.gz" {
t.Fatalf("got filename=%q", filename)
}
if content != "release bytes" {
t.Fatalf("got content=%q", content)
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(types.Attachment{ID: 9, Name: filename})
}))
defer srv.Close()
f := NewForge(srv.URL, "tok")
attachment, err := f.Releases.CreateAttachment(
context.Background(),
"core",
"go-forge",
1,
&ReleaseAttachmentUploadOptions{Name: "linux-amd64"},
"release.tar.gz",
bytes.NewBufferString("release bytes"),
)
if err != nil {
t.Fatal(err)
}
if attachment.Name != "release.tar.gz" {
t.Fatalf("got name=%q", attachment.Name)
}
}
func TestReleaseService_CreateAttachmentExternalURL_Good(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/core/go-forge/releases/1/assets" {
t.Errorf("wrong path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("name"); got != "docs" {
t.Fatalf("got name=%q", got)
}
fields, filename, content := readMultipartReleaseAttachment(t, r)
if !reflect.DeepEqual(fields, map[string]string{"external_url": "https://example.com/release.tar.gz"}) {
t.Fatalf("got fields=%#v", fields)
}
if filename != "" || content != "" {
t.Fatalf("unexpected file upload: filename=%q content=%q", filename, content)
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(types.Attachment{ID: 10, Name: "docs"})
}))
defer srv.Close()
f := NewForge(srv.URL, "tok")
attachment, err := f.Releases.CreateAttachment(
context.Background(),
"core",
"go-forge",
1,
&ReleaseAttachmentUploadOptions{Name: "docs", ExternalURL: "https://example.com/release.tar.gz"},
"",
nil,
)
if err != nil {
t.Fatal(err)
}
if attachment.Name != "docs" {
t.Fatalf("got name=%q", attachment.Name)
}
}