diff --git a/client.go b/client.go index cf9214d..472354c 100644 --- a/client.go +++ b/client.go @@ -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 { diff --git a/issues.go b/issues.go index 573c459..14a5f1e 100644 --- a/issues.go +++ b/issues.go @@ -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 diff --git a/releases.go b/releases.go index ed3a841..ebd5480 100644 --- a/releases.go +++ b/releases.go @@ -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))) diff --git a/releases_test.go b/releases_test.go index c0b22c3..b0410d4 100644 --- a/releases_test.go +++ b/releases_test.go @@ -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) + } +}