From 50f4183838a0d3c1cdafb10dffe79d88cd5c6fde Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 7 Apr 2026 14:57:24 +0100 Subject: [PATCH 01/15] fix: migrate module paths from forge.lthn.ai to dappco.re Upgrade go-io to v0.4.1 and go-core to v0.8.0-alpha.1 to resolve indirect dependency on forge.lthn.ai/core/go-log (old module path). Co-Authored-By: Virgil --- go.mod | 6 ++---- go.sum | 10 ++++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index b3a3c6b..e1ee4da 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,7 @@ module dappco.re/go/core/forge go 1.26.0 require ( - dappco.re/go/core v0.4.7 - dappco.re/go/core/io v0.2.0 + dappco.re/go/core v0.8.0-alpha.1 + dappco.re/go/core/io v0.4.1 github.com/goccy/go-json v0.10.6 ) - -require dappco.re/go/core/log v0.0.4 // indirect diff --git a/go.sum b/go.sum index 8169f53..761d3d1 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ -dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= -dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= -dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= -dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= -forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= -forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/io v0.4.1 h1:15dm7ldhFIAuZOrBiQG6XVZDpSvCxtZsUXApwTAB3wQ= +dappco.re/go/core/io v0.4.1/go.mod h1:w71dukyunczLb8frT9JOd5B78PjwWQD3YAXiCt3AcPA= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= From a4ec399b0ffd655dee9105f255c14e1a09053ec9 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 15:58:59 +0100 Subject: [PATCH 02/15] feat(forge): pagination + client + config alignment 5.4-mini pass. Build + tests clean. - pagination.go: refined cursor/next handling (+39 lines) - client.go, config.go: small alignment with RFC - ax_stringer_test, pagination_test, client_test: updated assertions Co-Authored-By: Virgil --- ax_stringer_test.go | 4 ++-- client.go | 27 ++++++++++++++++++++++----- client_test.go | 2 +- config.go | 3 ++- pagination.go | 39 ++++++++++++++++++++++++++------------- pagination_test.go | 2 +- 6 files changed, 54 insertions(+), 23 deletions(-) diff --git a/ax_stringer_test.go b/ax_stringer_test.go index e638ca5..7f17317 100644 --- a/ax_stringer_test.go +++ b/ax_stringer_test.go @@ -35,8 +35,8 @@ func TestParams_String_NilSafe(t *testing.T) { } func TestListOptions_String_Good(t *testing.T) { - opts := ListOptions{Page: 2, Limit: 25} - want := "forge.ListOptions{page=2, limit=25}" + opts := ListOptions{Page: 2, PageSize: 25} + want := "forge.ListOptions{page=2, page_size=25}" if got := opts.String(); got != want { t.Fatalf("got String()=%q, want %q", got, want) } diff --git a/client.go b/client.go index 1b650a7..dbc1948 100644 --- a/client.go +++ b/client.go @@ -365,7 +365,9 @@ func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte return nil, core.E("Client.PostRaw", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } req.Header.Set("Content-Type", "application/json") if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) @@ -399,7 +401,9 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er return nil, core.E("Client.PostText", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } req.Header.Set("Accept", "text/html") req.Header.Set("Content-Type", "text/plain") if c.userAgent != "" { @@ -466,7 +470,9 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s return core.E("Client.PostMultipart", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } req.Header.Set("Content-Type", writer.FormDataContentType()) if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) @@ -505,7 +511,9 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { return nil, core.E("Client.GetRaw", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } @@ -552,7 +560,9 @@ func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) return nil, core.E("Client.doJSON", "forge: create request", err) } - req.Header.Set("Authorization", "token "+c.token) + if auth := c.authorizationHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") @@ -617,3 +627,10 @@ func (c *Client) updateRateLimit(resp *http.Response) { c.rateLimit.Reset, _ = strconv.ParseInt(reset, 10, 64) } } + +func (c *Client) authorizationHeader() string { + if c == nil || c.token == "" { + return "" + } + return "Bearer " + c.token +} diff --git a/client_test.go b/client_test.go index 818383a..3b41be0 100644 --- a/client_test.go +++ b/client_test.go @@ -16,7 +16,7 @@ func TestClient_Get_Good(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } - if r.Header.Get("Authorization") != "token test-token" { + if r.Header.Get("Authorization") != "Bearer test-token" { t.Errorf("missing auth header") } if r.URL.Path != "/api/v1/user" { diff --git a/config.go b/config.go index 6da3f02..469acf2 100644 --- a/config.go +++ b/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" core "dappco.re/go/core" coreio "dappco.re/go/core/io" @@ -49,7 +50,7 @@ func readConfigFile() (url, token string, err error) { data, err := coreio.Local.Read(path) if err != nil { - if os.IsNotExist(err) { + if os.IsNotExist(err) || strings.Contains(err.Error(), "no such file or directory") { return "", "", nil } return "", "", core.E("ResolveConfig", "forge: read config file", err) diff --git a/pagination.go b/pagination.go index 203b15a..b6f567e 100644 --- a/pagination.go +++ b/pagination.go @@ -10,7 +10,10 @@ import ( core "dappco.re/go/core" ) -const defaultPageLimit = 50 +const defaultPageSize = 50 + +// defaultPageLimit is retained for compatibility with existing call sites. +const defaultPageLimit = defaultPageSize // ListOptions controls pagination. // @@ -19,8 +22,10 @@ const defaultPageLimit = 50 // opts := forge.ListOptions{Page: 1, Limit: 50} // _ = opts type ListOptions struct { - Page int // 1-based page number - Limit int // items per page (default 50) + Page int // 1-based page number + PageSize int // items per page (default 50) + // Limit is a compatibility alias for PageSize. + Limit int } // String returns a safe summary of the pagination options. @@ -29,11 +34,15 @@ type ListOptions struct { // // _ = forge.DefaultList.String() func (o ListOptions) String() string { + pageSize := o.PageSize + if pageSize == 0 { + pageSize = o.Limit + } return core.Concat( "forge.ListOptions{page=", strconv.Itoa(o.Page), - ", limit=", - strconv.Itoa(o.Limit), + ", page_size=", + strconv.Itoa(pageSize), "}", ) } @@ -51,7 +60,7 @@ func (o ListOptions) GoString() string { return o.String() } // // page, err := forge.ListPage[types.Repository](ctx, client, path, nil, forge.DefaultList) // _ = page -var DefaultList = ListOptions{Page: 1, Limit: defaultPageLimit} +var DefaultList = ListOptions{Page: 1, PageSize: defaultPageSize} // PagedResult holds a single page of results with metadata. // @@ -108,8 +117,12 @@ func ListPage[T any](ctx context.Context, c *Client, path string, query map[stri if opts.Page < 1 { opts.Page = 1 } - if opts.Limit < 1 { - opts.Limit = defaultPageLimit + pageSize := opts.PageSize + if pageSize < 1 { + pageSize = opts.Limit + } + if pageSize < 1 { + pageSize = defaultPageSize } u, err := url.Parse(path) @@ -119,7 +132,7 @@ func ListPage[T any](ctx context.Context, c *Client, path string, query map[stri q := u.Query() q.Set("page", strconv.Itoa(opts.Page)) - q.Set("limit", strconv.Itoa(opts.Limit)) + q.Set("limit", strconv.Itoa(pageSize)) for k, v := range query { q.Set(k, v) } @@ -139,8 +152,8 @@ func ListPage[T any](ctx context.Context, c *Client, path string, query map[stri Page: opts.Page, // If totalCount is provided, use it to determine if there are more items. // Otherwise, assume there are more if we got a full page. - HasMore: (totalCount > 0 && (opts.Page-1)*opts.Limit+len(items) < totalCount) || - (totalCount == 0 && len(items) >= opts.Limit), + HasMore: (totalCount > 0 && (opts.Page-1)*pageSize+len(items) < totalCount) || + (totalCount == 0 && len(items) >= pageSize), }, nil } @@ -155,7 +168,7 @@ func ListAll[T any](ctx context.Context, c *Client, path string, query map[strin page := 1 for { - result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, Limit: defaultPageLimit}) + result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, PageSize: defaultPageSize}) if err != nil { return nil, err } @@ -180,7 +193,7 @@ func ListIter[T any](ctx context.Context, c *Client, path string, query map[stri return func(yield func(T, error) bool) { page := 1 for { - result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, Limit: defaultPageLimit}) + result, err := ListPage[T](ctx, c, path, query, ListOptions{Page: page, PageSize: defaultPageSize}) if err != nil { yield(*new(T), err) return diff --git a/pagination_test.go b/pagination_test.go index 89e650c..1c1cc35 100644 --- a/pagination_test.go +++ b/pagination_test.go @@ -110,7 +110,7 @@ func TestListPage_QueryParams_Good(t *testing.T) { c := NewClient(srv.URL, "tok") _, err := ListPage[map[string]int](context.Background(), c, "/api/v1/repos", - map[string]string{"state": "open"}, ListOptions{Page: 2, Limit: 25}) + map[string]string{"state": "open"}, ListOptions{Page: 2, PageSize: 25}) if err != nil { t.Fatal(err) } From 2d95b3bf03950e1a1bd70d825730a0dc9f87deea Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 18:28:05 +0100 Subject: [PATCH 03/15] =?UTF-8?q?feat(forge):=20RFC=20API=20surface=20alig?= =?UTF-8?q?nment=20=E2=80=94=20named=20methods,=20compat=20options,=20pagi?= =?UTF-8?q?nation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 10 service files: add RFC-named methods + aliases (repos, issues, pulls, orgs, users, releases, milestones, labels, webhooks, contents) - types/list_options_compat.go: new ListIssueOption + ListPullRequestsOption shapes - types/hook.go: CreateHookOption.Config accepts generic map input - types/misc.go: MergePullRequestOption.MergeStyle compat field - users.go + pulls.go: honor PageSize alongside legacy Limit alias Verified: GOWORK=off go test ./... passes Co-Authored-By: Virgil --- contents.go | 5 ++ issues.go | 165 +++++++++++++++++++++++++++++++++-- labels.go | 10 +++ milestones.go | 30 +++++++ orgs.go | 37 ++++++++ pulls.go | 121 +++++++++++++++++++++---- releases.go | 5 ++ repos.go | 33 ++++++- types/hook.go | 12 +-- types/list_options_compat.go | 37 ++++++++ types/misc.go | 1 + users.go | 28 ++++-- webhooks.go | 30 +++++++ 13 files changed, 475 insertions(+), 39 deletions(-) create mode 100644 types/list_options_compat.go diff --git a/contents.go b/contents.go index 78d5c21..d0c8aa9 100644 --- a/contents.go +++ b/contents.go @@ -73,6 +73,11 @@ func (s *ContentService) GetFile(ctx context.Context, owner, repo, filepath stri return &out, nil } +// GetContents returns metadata and content for a file in a repository. +func (s *ContentService) GetContents(ctx context.Context, owner, repo, filepath string) (*types.ContentsResponse, error) { + return s.GetFile(ctx, owner, repo, filepath) +} + // CreateFile creates a new file in a repository. func (s *ContentService) CreateFile(ctx context.Context, owner, repo, filepath string, opts *types.CreateFileOptions) (*types.FileResponse, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/contents/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) diff --git a/issues.go b/issues.go index b26c4a9..d627310 100644 --- a/issues.go +++ b/issues.go @@ -28,6 +28,7 @@ type IssueService struct { // opts := forge.IssueListOptions{State: "open", Labels: "bug"} type IssueListOptions struct { State string + Sort string Labels string Query string Type string @@ -43,6 +44,7 @@ type IssueListOptions struct { func (o IssueListOptions) String() string { return optionString("forge.IssueListOptions", "state", o.State, + "sort", o.Sort, "labels", o.Labels, "q", o.Query, "type", o.Type, @@ -63,6 +65,9 @@ func (o IssueListOptions) queryParams() map[string]string { if o.State != "" { query["state"] = o.State } + if o.Sort != "" { + query["sort"] = o.Sort + } if o.Labels != "" { query["labels"] = o.Labels } @@ -160,6 +165,21 @@ func newIssueService(c *Client) *IssueService { } } +// GetIssue returns a single issue by index. +func (s *IssueService) GetIssue(ctx context.Context, owner, repo string, index int64) (*types.Issue, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index))) +} + +// EditIssue updates an existing issue. +func (s *IssueService) EditIssue(ctx context.Context, owner, repo string, index int64, opts *types.EditIssueOption) (*types.Issue, error) { + return s.Update(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index)), opts) +} + +// DeleteIssue deletes an issue. +func (s *IssueService) DeleteIssue(ctx context.Context, owner, repo string, index int64) error { + return s.Delete(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index))) +} + // SearchIssuesOptions controls filtering for the global issue search endpoint. // // Usage: @@ -276,17 +296,27 @@ func (s *IssueService) IterSearchIssues(ctx context.Context, opts SearchIssuesOp } // ListIssues returns all issues in a repository. -func (s *IssueService) ListIssues(ctx context.Context, owner, repo string, filters ...IssueListOptions) ([]types.Issue, error) { +func (s *IssueService) ListIssues(ctx context.Context, owner, repo string, filters ...any) ([]types.Issue, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) return ListAll[types.Issue](ctx, s.client, path, issueListQuery(filters...)) } // IterIssues returns an iterator over all issues in a repository. -func (s *IssueService) IterIssues(ctx context.Context, owner, repo string, filters ...IssueListOptions) iter.Seq2[types.Issue, error] { +func (s *IssueService) IterIssues(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.Issue, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) return ListIter[types.Issue](ctx, s.client, path, issueListQuery(filters...)) } +// ListRepoIssues returns all issues in a repository. +func (s *IssueService) ListRepoIssues(ctx context.Context, owner, repo string, filters ...any) ([]types.Issue, error) { + return s.ListIssues(ctx, owner, repo, filters...) +} + +// IterRepoIssues returns an iterator over all issues in a repository. +func (s *IssueService) IterRepoIssues(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.Issue, error] { + return s.IterIssues(ctx, owner, repo, filters...) +} + // CreateIssue creates a new issue in a repository. func (s *IssueService) CreateIssue(ctx context.Context, owner, repo string, opts *types.CreateIssueOption) (*types.Issue, error) { var out types.Issue @@ -430,12 +460,42 @@ func (s *IssueService) ListComments(ctx context.Context, owner, repo string, ind return ListAll[types.Comment](ctx, s.client, path, nil) } +// ListIssueComments returns all comments on an issue. +func (s *IssueService) ListIssueComments(ctx context.Context, owner, repo string, index int64) ([]types.Comment, error) { + return s.ListComments(ctx, owner, repo, index) +} + // IterComments returns an iterator over all comments on an issue. func (s *IssueService) IterComments(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Comment, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return ListIter[types.Comment](ctx, s.client, path, nil) } +// IterIssueComments returns an iterator over all comments on an issue. +func (s *IssueService) IterIssueComments(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.Comment, error] { + return s.IterComments(ctx, owner, repo, index) +} + +// GetIssueComment returns a single comment on an issue. +func (s *IssueService) GetIssueComment(ctx context.Context, owner, repo string, index, id int64) (*types.Comment, error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(id))) + var out types.Comment + if err := s.client.Get(ctx, path, &out); err != nil { + return nil, err + } + return &out, nil +} + +// EditIssueComment updates an issue comment. +func (s *IssueService) EditIssueComment(ctx context.Context, owner, repo string, index, id int64, opts *types.EditIssueCommentOption) (*types.Comment, error) { + return s.EditComment(ctx, owner, repo, index, id, opts) +} + +// DeleteIssueComment deletes an issue comment. +func (s *IssueService) DeleteIssueComment(ctx context.Context, owner, repo string, index, id int64) error { + return s.DeleteComment(ctx, owner, repo, index, id) +} + // CreateComment creates a comment on an issue. func (s *IssueService) CreateComment(ctx context.Context, owner, repo string, index int64, body string) (*types.Comment, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index))) @@ -529,11 +589,30 @@ func (s *IssueService) DeleteCommentReaction(ctx context.Context, owner, repo st return s.client.DeleteWithBody(ctx, path, types.EditReactionOption{Reaction: reaction}) } -func issueListQuery(filters ...IssueListOptions) map[string]string { +func issueListQuery(filters ...any) map[string]string { query := make(map[string]string, len(filters)) for _, filter := range filters { - for key, value := range filter.queryParams() { - query[key] = value + switch v := filter.(type) { + case IssueListOptions: + for key, value := range issueListQueryFromOption(v) { + query[key] = value + } + case *IssueListOptions: + if v != nil { + for key, value := range issueListQueryFromOption(*v) { + query[key] = value + } + } + case types.ListIssueOption: + for key, value := range issueListQueryFromCompat(v) { + query[key] = value + } + case *types.ListIssueOption: + if v != nil { + for key, value := range issueListQueryFromCompat(*v) { + query[key] = value + } + } } } if len(query) == 0 { @@ -542,6 +621,82 @@ func issueListQuery(filters ...IssueListOptions) map[string]string { return query } +func issueListQueryFromOption(filter IssueListOptions) map[string]string { + query := make(map[string]string, 10) + if filter.State != "" { + query["state"] = filter.State + } + if filter.Sort != "" { + query["sort"] = filter.Sort + } + if filter.Labels != "" { + query["labels"] = filter.Labels + } + if filter.Query != "" { + query["q"] = filter.Query + } + if filter.Type != "" { + query["type"] = filter.Type + } + if filter.Milestones != "" { + query["milestones"] = filter.Milestones + } + if filter.Since != nil { + query["since"] = filter.Since.Format(time.RFC3339) + } + if filter.Before != nil { + query["before"] = filter.Before.Format(time.RFC3339) + } + if filter.CreatedBy != "" { + query["created_by"] = filter.CreatedBy + } + if filter.AssignedBy != "" { + query["assigned_by"] = filter.AssignedBy + } + if filter.MentionedBy != "" { + query["mentioned_by"] = filter.MentionedBy + } + return query +} + +func issueListQueryFromCompat(filter types.ListIssueOption) map[string]string { + query := make(map[string]string, 10) + if filter.State != "" { + query["state"] = filter.State + } + if filter.Sort != "" { + query["sort"] = filter.Sort + } + if filter.Labels != "" { + query["labels"] = filter.Labels + } + if filter.Query != "" { + query["q"] = filter.Query + } + if filter.Type != "" { + query["type"] = filter.Type + } + if filter.Milestones != "" { + query["milestones"] = filter.Milestones + } + if filter.Since != nil { + query["since"] = filter.Since.Format(time.RFC3339) + } + if filter.Before != nil { + query["before"] = filter.Before.Format(time.RFC3339) + } + if filter.CreatedBy != "" { + query["created_by"] = filter.CreatedBy + } + if filter.AssignedBy != "" { + query["assigned_by"] = filter.AssignedBy + } + if filter.MentionedBy != "" { + query["mentioned_by"] = filter.MentionedBy + } + return query +} + func attachmentUploadQuery(opts *AttachmentUploadOptions) map[string]string { if opts == nil { return nil diff --git a/labels.go b/labels.go index 37a12bb..a7d66ee 100644 --- a/labels.go +++ b/labels.go @@ -28,12 +28,22 @@ func (s *LabelService) ListRepoLabels(ctx context.Context, owner, repo string) ( return ListAll[types.Label](ctx, s.client, path, nil) } +// ListLabels returns all labels for a repository. +func (s *LabelService) ListLabels(ctx context.Context, owner, repo string) ([]types.Label, error) { + return s.ListRepoLabels(ctx, owner, repo) +} + // IterRepoLabels returns an iterator over all labels for a repository. func (s *LabelService) IterRepoLabels(ctx context.Context, owner, repo string) iter.Seq2[types.Label, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels", pathParams("owner", owner, "repo", repo)) return ListIter[types.Label](ctx, s.client, path, nil) } +// IterLabels returns an iterator over all labels for a repository. +func (s *LabelService) IterLabels(ctx context.Context, owner, repo string) iter.Seq2[types.Label, error] { + return s.IterRepoLabels(ctx, owner, repo) +} + // GetRepoLabel returns a single label by ID. func (s *LabelService) GetRepoLabel(ctx context.Context, owner, repo string, id int64) (*types.Label, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) diff --git a/milestones.go b/milestones.go index bdf1050..093ff67 100644 --- a/milestones.go +++ b/milestones.go @@ -53,6 +53,16 @@ func newMilestoneService(c *Client) *MilestoneService { return &MilestoneService{client: c} } +// ListMilestones returns all milestones for a repository. +func (s *MilestoneService) ListMilestones(ctx context.Context, owner, repo string, filters ...MilestoneListOptions) ([]types.Milestone, error) { + return s.ListAll(ctx, pathParams("owner", owner, "repo", repo), filters...) +} + +// IterMilestones returns an iterator over all milestones for a repository. +func (s *MilestoneService) IterMilestones(ctx context.Context, owner, repo string, filters ...MilestoneListOptions) iter.Seq2[types.Milestone, error] { + return s.Iter(ctx, pathParams("owner", owner, "repo", repo), filters...) +} + // List returns a single page of milestones for a repository. func (s *MilestoneService) List(ctx context.Context, params Params, opts ListOptions, filters ...MilestoneListOptions) (*PagedResult[types.Milestone], error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", params) @@ -81,6 +91,11 @@ func (s *MilestoneService) Get(ctx context.Context, owner, repo string, id int64 return &out, nil } +// GetMilestone returns a single milestone by ID. +func (s *MilestoneService) GetMilestone(ctx context.Context, owner, repo string, id int64) (*types.Milestone, error) { + return s.Get(ctx, owner, repo, id) +} + // Create creates a new milestone. func (s *MilestoneService) Create(ctx context.Context, owner, repo string, opts *types.CreateMilestoneOption) (*types.Milestone, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones", pathParams("owner", owner, "repo", repo)) @@ -91,6 +106,11 @@ func (s *MilestoneService) Create(ctx context.Context, owner, repo string, opts return &out, nil } +// CreateMilestone creates a new milestone. +func (s *MilestoneService) CreateMilestone(ctx context.Context, owner, repo string, opts *types.CreateMilestoneOption) (*types.Milestone, error) { + return s.Create(ctx, owner, repo, opts) +} + // Edit updates an existing milestone. func (s *MilestoneService) Edit(ctx context.Context, owner, repo string, id int64, opts *types.EditMilestoneOption) (*types.Milestone, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) @@ -101,12 +121,22 @@ func (s *MilestoneService) Edit(ctx context.Context, owner, repo string, id int6 return &out, nil } +// EditMilestone updates an existing milestone. +func (s *MilestoneService) EditMilestone(ctx context.Context, owner, repo string, id int64, opts *types.EditMilestoneOption) (*types.Milestone, error) { + return s.Edit(ctx, owner, repo, id, opts) +} + // Delete removes a milestone. func (s *MilestoneService) Delete(ctx context.Context, owner, repo string, id int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/milestones/{id}", pathParams("owner", owner, "repo", repo, "id", int64String(id))) return s.client.Delete(ctx, path) } +// DeleteMilestone deletes a milestone. +func (s *MilestoneService) DeleteMilestone(ctx context.Context, owner, repo string, id int64) error { + return s.Delete(ctx, owner, repo, id) +} + func milestoneQuery(filters ...MilestoneListOptions) map[string]string { if len(filters) == 0 { return nil diff --git a/orgs.go b/orgs.go index c76540f..be69fde 100644 --- a/orgs.go +++ b/orgs.go @@ -53,11 +53,38 @@ func newOrgService(c *Client) *OrgService { } } +// GetOrg returns an organisation by name. +func (s *OrgService) GetOrg(ctx context.Context, org string) (*types.Organization, error) { + return s.Get(ctx, pathParams("org", org)) +} + +// UpdateOrg updates an organisation. +func (s *OrgService) UpdateOrg(ctx context.Context, org string, opts *types.EditOrgOption) (*types.Organization, error) { + return s.Update(ctx, pathParams("org", org), opts) +} + +// DeleteOrg deletes an organisation. +func (s *OrgService) DeleteOrg(ctx context.Context, org string) error { + return s.Delete(ctx, pathParams("org", org)) +} + // ListOrgs returns all organisations. func (s *OrgService) ListOrgs(ctx context.Context) ([]types.Organization, error) { return ListAll[types.Organization](ctx, s.client, "/api/v1/orgs", nil) } +// ListOrgTeams returns all teams in an organisation. +func (s *OrgService) ListOrgTeams(ctx context.Context, org string) ([]types.Team, error) { + path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) + return ListAll[types.Team](ctx, s.client, path, nil) +} + +// IterOrgTeams returns an iterator over all teams in an organisation. +func (s *OrgService) IterOrgTeams(ctx context.Context, org string) iter.Seq2[types.Team, error] { + path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) + return ListIter[types.Team](ctx, s.client, path, nil) +} + // IterOrgs returns an iterator over all organisations. func (s *OrgService) IterOrgs(ctx context.Context) iter.Seq2[types.Organization, error] { return ListIter[types.Organization](ctx, s.client, "/api/v1/orgs", nil) @@ -78,12 +105,22 @@ func (s *OrgService) ListMembers(ctx context.Context, org string) ([]types.User, return ListAll[types.User](ctx, s.client, path, nil) } +// ListOrgMembers returns all members of an organisation. +func (s *OrgService) ListOrgMembers(ctx context.Context, org string) ([]types.User, error) { + return s.ListMembers(ctx, org) +} + // IterMembers returns an iterator over all members of an organisation. func (s *OrgService) IterMembers(ctx context.Context, org string) iter.Seq2[types.User, error] { path := ResolvePath("/api/v1/orgs/{org}/members", pathParams("org", org)) return ListIter[types.User](ctx, s.client, path, nil) } +// IterOrgMembers returns an iterator over all members of an organisation. +func (s *OrgService) IterOrgMembers(ctx context.Context, org string) iter.Seq2[types.User, error] { + return s.IterMembers(ctx, org) +} + // AddMember adds a user to an organisation. func (s *OrgService) AddMember(ctx context.Context, org, username string) error { path := ResolvePath("/api/v1/orgs/{org}/members/{username}", pathParams("org", org, "username", username)) diff --git a/pulls.go b/pulls.go index 56cd6eb..3aa3392 100644 --- a/pulls.go +++ b/pulls.go @@ -76,12 +76,12 @@ func newPullService(c *Client) *PullService { } // ListPullRequests returns all pull requests in a repository. -func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string, filters ...PullListOptions) ([]types.PullRequest, error) { +func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string, filters ...any) ([]types.PullRequest, error) { return s.listAll(ctx, owner, repo, filters...) } // IterPullRequests returns an iterator over all pull requests in a repository. -func (s *PullService) IterPullRequests(ctx context.Context, owner, repo string, filters ...PullListOptions) iter.Seq2[types.PullRequest, error] { +func (s *PullService) IterPullRequests(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.PullRequest, error] { return s.listIter(ctx, owner, repo, filters...) } @@ -94,11 +94,37 @@ func (s *PullService) CreatePullRequest(ctx context.Context, owner, repo string, return &out, nil } +// GetPullRequest returns a single pull request by index. +func (s *PullService) GetPullRequest(ctx context.Context, owner, repo string, index int64) (*types.PullRequest, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index))) +} + +// EditPullRequest updates an existing pull request. +func (s *PullService) EditPullRequest(ctx context.Context, owner, repo string, index int64, opts *types.EditPullRequestOption) (*types.PullRequest, error) { + return s.Resource.Update(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index)), opts) +} + +// DeletePullRequest deletes a pull request. +func (s *PullService) DeletePullRequest(ctx context.Context, owner, repo string, index int64) error { + return s.Delete(ctx, pathParams("owner", owner, "repo", repo, "index", int64String(index))) +} + // Merge merges a pull request. Method is one of "merge", "rebase", "rebase-merge", "squash", "fast-forward-only", "manually-merged". func (s *PullService) Merge(ctx context.Context, owner, repo string, index int64, method string) error { + return s.MergePullRequest(ctx, owner, repo, index, &types.MergePullRequestOption{Do: method}) +} + +// MergePullRequest merges a pull request. +func (s *PullService) MergePullRequest(ctx context.Context, owner, repo string, index int64, opts *types.MergePullRequestOption) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/merge", pathParams("owner", owner, "repo", repo, "index", int64String(index))) - body := map[string]string{"Do": method} - return s.client.Post(ctx, path, body, nil) + if opts != nil { + body := *opts + if body.Do == "" { + body.Do = body.MergeStyle + } + opts = &body + } + return s.client.Post(ctx, path, opts, nil) } // CancelScheduledAutoMerge cancels the scheduled auto merge for a pull request. @@ -137,12 +163,22 @@ func (s *PullService) ListReviews(ctx context.Context, owner, repo string, index return ListAll[types.PullReview](ctx, s.client, path, nil) } +// ListPullReviews returns all reviews on a pull request. +func (s *PullService) ListPullReviews(ctx context.Context, owner, repo string, index int64) ([]types.PullReview, error) { + return s.ListReviews(ctx, owner, repo, index) +} + // IterReviews returns an iterator over all reviews on a pull request. func (s *PullService) IterReviews(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.PullReview, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", pathParams("owner", owner, "repo", repo, "index", int64String(index))) return ListIter[types.PullReview](ctx, s.client, path, nil) } +// IterPullReviews returns an iterator over all reviews on a pull request. +func (s *PullService) IterPullReviews(ctx context.Context, owner, repo string, index int64) iter.Seq2[types.PullReview, error] { + return s.IterReviews(ctx, owner, repo, index) +} + // ListFiles returns all changed files on a pull request. func (s *PullService) ListFiles(ctx context.Context, owner, repo string, index int64) ([]types.ChangedFile, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/files", pathParams("owner", owner, "repo", repo, "index", int64String(index))) @@ -218,18 +254,32 @@ func (s *PullService) GetReview(ctx context.Context, owner, repo string, index, return &out, nil } +// GetPullReview returns a single pull request review. +func (s *PullService) GetPullReview(ctx context.Context, owner, repo string, index, reviewID int64) (*types.PullReview, error) { + return s.GetReview(ctx, owner, repo, index, reviewID) +} + // DeleteReview deletes a pull request review. func (s *PullService) DeleteReview(ctx context.Context, owner, repo string, index, reviewID int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) return s.client.Delete(ctx, path) } -func (s *PullService) listPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...PullListOptions) (*PagedResult[types.PullRequest], error) { +// DeletePullReview deletes a pull request review. +func (s *PullService) DeletePullReview(ctx context.Context, owner, repo string, index, reviewID int64) error { + return s.DeleteReview(ctx, owner, repo, index, reviewID) +} + +func (s *PullService) listPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.PullRequest], error) { if opts.Page < 1 { opts.Page = 1 } - if opts.Limit < 1 { - opts.Limit = defaultPageLimit + pageSize := opts.PageSize + if pageSize < 1 { + pageSize = opts.Limit + } + if pageSize < 1 { + pageSize = defaultPageLimit } path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) @@ -240,10 +290,8 @@ func (s *PullService) listPage(ctx context.Context, owner, repo string, opts Lis values := u.Query() values.Set("page", strconv.Itoa(opts.Page)) - values.Set("limit", strconv.Itoa(opts.Limit)) - for _, filter := range filters { - filter.addQuery(values) - } + values.Set("limit", strconv.Itoa(pageSize)) + addPullFilters(values, filters...) u.RawQuery = values.Encode() var items []types.PullRequest @@ -257,17 +305,17 @@ func (s *PullService) listPage(ctx context.Context, owner, repo string, opts Lis Items: items, TotalCount: totalCount, Page: opts.Page, - HasMore: (totalCount > 0 && (opts.Page-1)*opts.Limit+len(items) < totalCount) || - (totalCount == 0 && len(items) >= opts.Limit), + HasMore: (totalCount > 0 && (opts.Page-1)*pageSize+len(items) < totalCount) || + (totalCount == 0 && len(items) >= pageSize), }, nil } -func (s *PullService) listAll(ctx context.Context, owner, repo string, filters ...PullListOptions) ([]types.PullRequest, error) { +func (s *PullService) listAll(ctx context.Context, owner, repo string, filters ...any) ([]types.PullRequest, error) { var all []types.PullRequest page := 1 for { - result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) + result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, PageSize: defaultPageLimit}, filters...) if err != nil { return nil, err } @@ -281,11 +329,11 @@ func (s *PullService) listAll(ctx context.Context, owner, repo string, filters . return all, nil } -func (s *PullService) listIter(ctx context.Context, owner, repo string, filters ...PullListOptions) iter.Seq2[types.PullRequest, error] { +func (s *PullService) listIter(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.PullRequest, error] { return func(yield func(types.PullRequest, error) bool) { page := 1 for { - result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) + result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, PageSize: defaultPageLimit}, filters...) if err != nil { yield(*new(types.PullRequest), err) return @@ -303,6 +351,45 @@ func (s *PullService) listIter(ctx context.Context, owner, repo string, filters } } +func addPullFilters(values url.Values, filters ...any) { + for _, filter := range filters { + switch v := filter.(type) { + case PullListOptions: + v.addQuery(values) + case *PullListOptions: + if v != nil { + v.addQuery(values) + } + case types.ListPullRequestsOption: + addCompatPullFilter(values, v) + case *types.ListPullRequestsOption: + if v != nil { + addCompatPullFilter(values, *v) + } + } + } +} + +func addCompatPullFilter(values url.Values, filter types.ListPullRequestsOption) { + if filter.State != "" { + values.Set("state", filter.State) + } + if filter.Sort != "" { + values.Set("sort", filter.Sort) + } + if filter.Milestone != 0 { + values.Set("milestone", strconv.FormatInt(filter.Milestone, 10)) + } + for _, label := range filter.Labels { + if label != 0 { + values.Add("labels", strconv.FormatInt(label, 10)) + } + } + if filter.Poster != "" { + values.Set("poster", filter.Poster) + } +} + // ListReviewComments returns all comments on a pull request review. func (s *PullService) ListReviewComments(ctx context.Context, owner, repo string, index, reviewID int64) ([]types.PullReviewComment, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) diff --git a/releases.go b/releases.go index 906d259..c4e7c27 100644 --- a/releases.go +++ b/releases.go @@ -131,6 +131,11 @@ func (s *ReleaseService) GetByTag(ctx context.Context, owner, repo, tag string) return &out, nil } +// GetRelease returns a release by its tag name. +func (s *ReleaseService) GetRelease(ctx context.Context, owner, repo, tag string) (*types.Release, error) { + return s.GetByTag(ctx, owner, repo, tag) +} + // GetLatest returns the most recent non-prerelease, non-draft release. func (s *ReleaseService) GetLatest(ctx context.Context, owner, repo string) (*types.Release, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/latest", pathParams("owner", owner, "repo", repo)) diff --git a/repos.go b/repos.go index 8b826a8..0957e2b 100644 --- a/repos.go +++ b/repos.go @@ -128,6 +128,21 @@ func newRepoService(c *Client) *RepoService { } } +// GetRepo returns a repository by owner and name. +func (s *RepoService) GetRepo(ctx context.Context, owner, repo string) (*types.Repository, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo)) +} + +// UpdateRepo updates an existing repository. +func (s *RepoService) UpdateRepo(ctx context.Context, owner, repo string, opts *types.EditRepoOption) (*types.Repository, error) { + return s.Update(ctx, pathParams("owner", owner, "repo", repo), opts) +} + +// DeleteRepo deletes a repository. +func (s *RepoService) DeleteRepo(ctx context.Context, owner, repo string) error { + return s.Delete(ctx, pathParams("owner", owner, "repo", repo)) +} + // Migrate imports a remote git repository into Forgejo. func (s *RepoService) Migrate(ctx context.Context, opts *types.MigrateRepoOptions) (*types.Repository, error) { var out types.Repository @@ -178,13 +193,23 @@ func (s *RepoService) IterOrgRepos(ctx context.Context, org string) iter.Seq2[ty return ListIter[types.Repository](ctx, s.client, path, nil) } -// ListUserRepos returns all repositories for the authenticated user. -func (s *RepoService) ListUserRepos(ctx context.Context) ([]types.Repository, error) { +// ListUserRepos returns all repositories for a user. +// When username is omitted, it returns repositories for the authenticated user. +func (s *RepoService) ListUserRepos(ctx context.Context, username ...string) ([]types.Repository, error) { + if len(username) > 0 && username[0] != "" { + path := ResolvePath("/api/v1/users/{username}/repos", pathParams("username", username[0])) + return ListAll[types.Repository](ctx, s.client, path, nil) + } return ListAll[types.Repository](ctx, s.client, "/api/v1/user/repos", nil) } -// IterUserRepos returns an iterator over all repositories for the authenticated user. -func (s *RepoService) IterUserRepos(ctx context.Context) iter.Seq2[types.Repository, error] { +// IterUserRepos returns an iterator over repositories for a user. +// When username is omitted, it returns repositories for the authenticated user. +func (s *RepoService) IterUserRepos(ctx context.Context, username ...string) iter.Seq2[types.Repository, error] { + if len(username) > 0 && username[0] != "" { + path := ResolvePath("/api/v1/users/{username}/repos", pathParams("username", username[0])) + return ListIter[types.Repository](ctx, s.client, path, nil) + } return ListIter[types.Repository](ctx, s.client, "/api/v1/user/repos", nil) } diff --git a/types/hook.go b/types/hook.go index 27015a8..e8bb26a 100644 --- a/types/hook.go +++ b/types/hook.go @@ -10,12 +10,12 @@ import "time" // // opts := CreateHookOption{Type: "example"} type CreateHookOption struct { - Active bool `json:"active,omitempty"` - AuthorizationHeader string `json:"authorization_header,omitempty"` - BranchFilter string `json:"branch_filter,omitempty"` - Config *CreateHookOptionConfig `json:"config"` - Events []string `json:"events,omitempty"` - Type string `json:"type"` + Active bool `json:"active,omitempty"` + AuthorizationHeader string `json:"authorization_header,omitempty"` + BranchFilter string `json:"branch_filter,omitempty"` + Config any `json:"config"` + Events []string `json:"events,omitempty"` + Type string `json:"type"` } // CreateHookOptionConfig — CreateHookOptionConfig has all config options in it required are "content_type" and "url" Required diff --git a/types/list_options_compat.go b/types/list_options_compat.go new file mode 100644 index 0000000..3a3d2d3 --- /dev/null +++ b/types/list_options_compat.go @@ -0,0 +1,37 @@ +// Compatibility types for RFC-style list options. + +package types + +import "time" + +// ListIssueOption is a compatibility alias for repository issue list filters. +// +// Usage: +// +// opts := ListIssueOption{State: "open", Sort: "created"} +type ListIssueOption struct { + AssignedBy string `json:"assigned_by,omitempty"` + Before *time.Time `json:"before,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + Labels string `json:"labels,omitempty"` + MentionedBy string `json:"mentioned_by,omitempty"` + Milestones string `json:"milestones,omitempty"` + Query string `json:"q,omitempty"` + Sort string `json:"sort,omitempty"` + State string `json:"state,omitempty"` + Since *time.Time `json:"since,omitempty"` + Type string `json:"type,omitempty"` +} + +// ListPullRequestsOption is a compatibility alias for repository pull request list filters. +// +// Usage: +// +// opts := ListPullRequestsOption{State: "open"} +type ListPullRequestsOption struct { + Labels []int64 `json:"labels,omitempty"` + Milestone int64 `json:"milestone,omitempty"` + Poster string `json:"poster,omitempty"` + Sort string `json:"sort,omitempty"` + State string `json:"state,omitempty"` +} diff --git a/types/misc.go b/types/misc.go index 3da46b9..40ef026 100644 --- a/types/misc.go +++ b/types/misc.go @@ -203,6 +203,7 @@ type MergePullRequestOption struct { MergeMessageField string `json:"MergeMessageField,omitempty"` MergeTitleField string `json:"MergeTitleField,omitempty"` MergeWhenChecksSucceed bool `json:"merge_when_checks_succeed,omitempty"` + MergeStyle string `json:"-"` } // MigrateRepoOptions — MigrateRepoOptions options for migrating repository's this is used to interact with api v1 diff --git a/users.go b/users.go index d0254a8..49b0567 100644 --- a/users.go +++ b/users.go @@ -86,6 +86,16 @@ func newUserService(c *Client) *UserService { } } +// GetUserByID returns a user by numeric ID. +func (s *UserService) GetUserByID(ctx context.Context, id int64) (*types.User, error) { + return s.GetUserByUsername(ctx, strconv.FormatInt(id, 10)) +} + +// GetUserByUsername returns a user by username. +func (s *UserService) GetUserByUsername(ctx context.Context, username string) (*types.User, error) { + return s.Get(ctx, pathParams("username", username)) +} + // GetCurrent returns the authenticated user. func (s *UserService) GetCurrent(ctx context.Context) (*types.User, error) { var out types.User @@ -127,8 +137,12 @@ func (s *UserService) SearchUsersPage(ctx context.Context, query string, pageOpt if pageOpts.Page < 1 { pageOpts.Page = 1 } - if pageOpts.Limit < 1 { - pageOpts.Limit = 50 + pageSize := pageOpts.PageSize + if pageSize < 1 { + pageSize = pageOpts.Limit + } + if pageSize < 1 { + pageSize = 50 } u, err := url.Parse("/api/v1/users/search") @@ -144,7 +158,7 @@ func (s *UserService) SearchUsersPage(ctx context.Context, query string, pageOpt } } q.Set("page", strconv.Itoa(pageOpts.Page)) - q.Set("limit", strconv.Itoa(pageOpts.Limit)) + q.Set("limit", strconv.Itoa(pageSize)) u.RawQuery = q.Encode() var out userSearchResults @@ -165,8 +179,8 @@ func (s *UserService) SearchUsersPage(ctx context.Context, query string, pageOpt Items: items, TotalCount: totalCount, Page: pageOpts.Page, - HasMore: (totalCount > 0 && (pageOpts.Page-1)*pageOpts.Limit+len(items) < totalCount) || - (totalCount == 0 && len(items) >= pageOpts.Limit), + HasMore: (totalCount > 0 && (pageOpts.Page-1)*pageSize+len(items) < totalCount) || + (totalCount == 0 && len(items) >= pageSize), }, nil } @@ -176,7 +190,7 @@ func (s *UserService) SearchUsers(ctx context.Context, query string, filters ... page := 1 for { - result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, Limit: 50}, filters...) + result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, PageSize: 50}, filters...) if err != nil { return nil, err } @@ -195,7 +209,7 @@ func (s *UserService) IterSearchUsers(ctx context.Context, query string, filters return func(yield func(types.User, error) bool) { page := 1 for { - result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, Limit: 50}, filters...) + result, err := s.SearchUsersPage(ctx, query, ListOptions{Page: page, PageSize: 50}, filters...) if err != nil { yield(*new(types.User), err) return diff --git a/webhooks.go b/webhooks.go index 0824aba..bd5ee9f 100644 --- a/webhooks.go +++ b/webhooks.go @@ -32,12 +32,22 @@ func (s *WebhookService) ListHooks(ctx context.Context, owner, repo string) ([]t return ListAll[types.Hook](ctx, s.client, path, nil) } +// ListRepoHooks returns all webhooks for a repository. +func (s *WebhookService) ListRepoHooks(ctx context.Context, owner, repo string) ([]types.Hook, error) { + return s.ListHooks(ctx, owner, repo) +} + // IterHooks returns an iterator over all webhooks for a repository. func (s *WebhookService) IterHooks(ctx context.Context, owner, repo string) iter.Seq2[types.Hook, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks", pathParams("owner", owner, "repo", repo)) return ListIter[types.Hook](ctx, s.client, path, nil) } +// IterRepoHooks returns an iterator over all webhooks for a repository. +func (s *WebhookService) IterRepoHooks(ctx context.Context, owner, repo string) iter.Seq2[types.Hook, error] { + return s.IterHooks(ctx, owner, repo) +} + // CreateHook creates a webhook for a repository. func (s *WebhookService) CreateHook(ctx context.Context, owner, repo string, opts *types.CreateHookOption) (*types.Hook, error) { var out types.Hook @@ -47,6 +57,26 @@ func (s *WebhookService) CreateHook(ctx context.Context, owner, repo string, opt return &out, nil } +// CreateRepoHook creates a webhook for a repository. +func (s *WebhookService) CreateRepoHook(ctx context.Context, owner, repo string, opts *types.CreateHookOption) (*types.Hook, error) { + return s.CreateHook(ctx, owner, repo, opts) +} + +// GetRepoHook returns a single webhook for a repository. +func (s *WebhookService) GetRepoHook(ctx context.Context, owner, repo string, id int64) (*types.Hook, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo, "id", int64String(id))) +} + +// EditRepoHook updates an existing webhook in a repository. +func (s *WebhookService) EditRepoHook(ctx context.Context, owner, repo string, id int64, opts *types.EditHookOption) (*types.Hook, error) { + return s.Update(ctx, pathParams("owner", owner, "repo", repo, "id", int64String(id)), opts) +} + +// DeleteRepoHook deletes a webhook from a repository. +func (s *WebhookService) DeleteRepoHook(ctx context.Context, owner, repo string, id int64) error { + return s.Delete(ctx, pathParams("owner", owner, "repo", repo, "id", int64String(id))) +} + // TestHook triggers a test delivery for a webhook. func (s *WebhookService) TestHook(ctx context.Context, owner, repo string, id int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks/{id}/tests", pathParams("owner", owner, "repo", repo, "id", int64String(id))) From 413dcd9adba34428fdf94d6a1ae926e25494a8ea Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 19:38:27 +0100 Subject: [PATCH 04/15] feat(forge): RFC commits API + pagination + content alias + CreateFile compat - commits.go: ListCommits / IterCommits / GetCommit RFC-facing methods - issues.go + pulls.go: honor RFC-style Page / PageSize / Limit on types.ListIssueOption + types.ListPullRequestsOption - types/list_options_compat.go: add pagination fields + new ListCommitsOption - types/content.go + misc.go: Content RFC compat alias - types/content_compat.go: marshal Content back to Forgejo content payload so RFC-shaped CreateFileOptions{Content: ...} works as written - compat_helpers.go: shared compat glue - Regression tests across commits_extra/issues_extra/pulls_extra/contents Verified: go test ./... passes Co-Authored-By: Virgil --- commits.go | 115 +++++++++++++++++++++++++++++++++++ commits_extra_test.go | 58 ++++++++++++++++++ compat_helpers.go | 17 ++++++ contents_test.go | 34 +++++++++++ issues.go | 39 ++++++++++++ issues_extra_test.go | 36 +++++++++++ pulls.go | 39 ++++++++++++ pulls_extra_test.go | 36 +++++++++++ types/content.go | 2 + types/content_compat.go | 69 +++++++++++++++++++++ types/list_options_compat.go | 23 +++++++ types/misc.go | 1 + 12 files changed, 469 insertions(+) create mode 100644 compat_helpers.go create mode 100644 types/content_compat.go diff --git a/commits.go b/commits.go index 8735b46..00808b6 100644 --- a/commits.go +++ b/commits.go @@ -110,6 +110,48 @@ func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, return &out, nil } +// ListCommits returns commits for a repository using RFC-compatible filters. +func (s *CommitService) ListCommits(ctx context.Context, owner, repo string, filters ...any) ([]types.Commit, error) { + params := pathParams("owner", owner, "repo", repo) + path := ResolvePath(commitCollectionPath, params) + query := commitCompatListQuery(filters...) + if pageOpts, ok := commitCompatPageOptions(filters...); ok { + page, err := ListPage[types.Commit](ctx, s.client, path, query, pageOpts) + if err != nil { + return nil, err + } + return page.Items, nil + } + return ListAll[types.Commit](ctx, s.client, path, query) +} + +// IterCommits returns an iterator over commits for a repository using RFC-compatible filters. +func (s *CommitService) IterCommits(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.Commit, error] { + params := pathParams("owner", owner, "repo", repo) + path := ResolvePath(commitCollectionPath, params) + query := commitCompatListQuery(filters...) + if pageOpts, ok := commitCompatPageOptions(filters...); ok { + return func(yield func(types.Commit, error) bool) { + page, err := ListPage[types.Commit](ctx, s.client, path, query, pageOpts) + if err != nil { + yield(*new(types.Commit), err) + return + } + for _, item := range page.Items { + if !yield(item, nil) { + return + } + } + } + } + return ListIter[types.Commit](ctx, s.client, path, query) +} + +// GetCommit returns a single commit by SHA or ref. +func (s *CommitService) GetCommit(ctx context.Context, owner, repo, sha string) (*types.Commit, error) { + return s.Get(ctx, pathParams("owner", owner, "repo", repo, "sha", sha)) +} + // GetDiffOrPatch returns a commit diff or patch as raw bytes. func (s *CommitService) GetDiffOrPatch(ctx context.Context, owner, repo, sha, diffType string) ([]byte, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/commits/{sha}.{diffType}", pathParams("owner", owner, "repo", repo, "sha", sha, "diffType", diffType)) @@ -225,3 +267,76 @@ func commitListQuery(filters ...CommitListOptions) map[string]string { } return query } + +func commitCompatListQuery(filters ...any) map[string]string { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + switch v := filter.(type) { + case CommitListOptions: + for key, value := range v.queryParams() { + query[key] = value + } + case *CommitListOptions: + if v != nil { + for key, value := range v.queryParams() { + query[key] = value + } + } + case types.ListCommitsOption: + for key, value := range commitListQueryFromCompat(v) { + query[key] = value + } + case *types.ListCommitsOption: + if v != nil { + for key, value := range commitListQueryFromCompat(*v) { + query[key] = value + } + } + } + } + if len(query) == 0 { + return nil + } + return query +} + +func commitListQueryFromCompat(filter types.ListCommitsOption) map[string]string { + query := make(map[string]string, 6) + if filter.Sha != "" { + query["sha"] = filter.Sha + } + if filter.Path != "" { + query["path"] = filter.Path + } + if filter.Stat != nil { + query["stat"] = strconv.FormatBool(*filter.Stat) + } + if filter.Verification != nil { + query["verification"] = strconv.FormatBool(*filter.Verification) + } + if filter.Files != nil { + query["files"] = strconv.FormatBool(*filter.Files) + } + if filter.Not != "" { + query["not"] = filter.Not + } + return query +} + +func commitCompatPageOptions(filters ...any) (ListOptions, bool) { + for _, filter := range filters { + switch v := filter.(type) { + case types.ListCommitsOption: + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + case *types.ListCommitsOption: + if v != nil { + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + } + } + } + return ListOptions{}, false +} diff --git a/commits_extra_test.go b/commits_extra_test.go index 379124b..541876d 100644 --- a/commits_extra_test.go +++ b/commits_extra_test.go @@ -34,3 +34,61 @@ func TestCommitService_GetCombinedStatusByRef_Good(t *testing.T) { t.Fatalf("got %#v", status) } } + +func TestCommitService_ListCommits_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/commits" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("sha"); got != "main" { + t.Errorf("got sha=%q, want %q", got, "main") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Commit{{SHA: "abc123"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + commits, err := f.Commits.ListCommits(context.Background(), "core", "go-forge", &types.ListCommitsOption{ + Sha: "main", + Page: 2, + PageSize: 25, + }) + if err != nil { + t.Fatal(err) + } + if len(commits) != 1 || commits[0].SHA != "abc123" { + t.Fatalf("got %#v", commits) + } +} + +func TestCommitService_GetCommit_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/git/commits/abc123" { + t.Errorf("wrong path: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(types.Commit{SHA: "abc123"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + commit, err := f.Commits.GetCommit(context.Background(), "core", "go-forge", "abc123") + if err != nil { + t.Fatal(err) + } + if commit.SHA != "abc123" { + t.Fatalf("got %#v", commit) + } +} diff --git a/compat_helpers.go b/compat_helpers.go new file mode 100644 index 0000000..f2cc9bb --- /dev/null +++ b/compat_helpers.go @@ -0,0 +1,17 @@ +package forge + +func compatListOptions(page, pageSize, limit int) (ListOptions, bool) { + if page == 0 && pageSize == 0 && limit == 0 { + return ListOptions{}, false + } + + opts := ListOptions{ + Page: page, + PageSize: pageSize, + Limit: limit, + } + if opts.Page < 1 { + opts.Page = 1 + } + return opts, true +} diff --git a/contents_test.go b/contents_test.go index abc0b6d..3275642 100644 --- a/contents_test.go +++ b/contents_test.go @@ -161,6 +161,40 @@ func TestContentService_CreateFile_Good(t *testing.T) { } } +func TestContentService_CreateFile_ContentCompat_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/contents/docs/rfc.md" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var opts types.CreateFileOptions + if err := json.NewDecoder(r.Body).Decode(&opts); err != nil { + t.Fatal(err) + } + if opts.ContentBase64 != "Y29tcGF0" { + t.Errorf("got content=%q, want %q", opts.ContentBase64, "Y29tcGF0") + } + json.NewEncoder(w).Encode(types.FileResponse{ + Content: &types.ContentsResponse{Name: "rfc.md", Path: "docs/rfc.md"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + resp, err := f.Contents.CreateFile(context.Background(), "core", "go-forge", "docs/rfc.md", &types.CreateFileOptions{ + Content: "Y29tcGF0", + Message: "create docs/rfc.md", + }) + if err != nil { + t.Fatal(err) + } + if resp.Content == nil || resp.Content.Path != "docs/rfc.md" { + t.Fatalf("got %#v", resp) + } +} + func TestContentService_UpdateFile_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { diff --git a/issues.go b/issues.go index d627310..01642bf 100644 --- a/issues.go +++ b/issues.go @@ -298,12 +298,33 @@ func (s *IssueService) IterSearchIssues(ctx context.Context, opts SearchIssuesOp // ListIssues returns all issues in a repository. func (s *IssueService) ListIssues(ctx context.Context, owner, repo string, filters ...any) ([]types.Issue, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) + if pageOpts, ok := issueListPageOptions(filters...); ok { + page, err := ListPage[types.Issue](ctx, s.client, path, issueListQuery(filters...), pageOpts) + if err != nil { + return nil, err + } + return page.Items, nil + } return ListAll[types.Issue](ctx, s.client, path, issueListQuery(filters...)) } // IterIssues returns an iterator over all issues in a repository. func (s *IssueService) IterIssues(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.Issue, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) + if pageOpts, ok := issueListPageOptions(filters...); ok { + return func(yield func(types.Issue, error) bool) { + page, err := ListPage[types.Issue](ctx, s.client, path, issueListQuery(filters...), pageOpts) + if err != nil { + yield(*new(types.Issue), err) + return + } + for _, item := range page.Items { + if !yield(item, nil) { + return + } + } + } + } return ListIter[types.Issue](ctx, s.client, path, issueListQuery(filters...)) } @@ -697,6 +718,24 @@ func issueListQueryFromCompat(filter types.ListIssueOption) map[string]string { return query } +func issueListPageOptions(filters ...any) (ListOptions, bool) { + for _, filter := range filters { + switch v := filter.(type) { + case types.ListIssueOption: + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + case *types.ListIssueOption: + if v != nil { + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + } + } + } + return ListOptions{}, false +} + func attachmentUploadQuery(opts *AttachmentUploadOptions) map[string]string { if opts == nil { return nil diff --git a/issues_extra_test.go b/issues_extra_test.go index 27d942d..64d42ba 100644 --- a/issues_extra_test.go +++ b/issues_extra_test.go @@ -61,3 +61,39 @@ func TestIssueService_CreateIssue_Good(t *testing.T) { t.Fatalf("got title=%q", issue.Title) } } + +func TestIssueService_ListRepoIssues_CompatPagination_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + w.Header().Set("X-Total-Count", "40") + json.NewEncoder(w).Encode([]types.Issue{{ID: 2, Title: "paged issue"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.ListRepoIssues(context.Background(), "core", "go-forge", &types.ListIssueOption{ + State: "open", + Page: 2, + PageSize: 25, + }) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 || issues[0].Title != "paged issue" { + t.Fatalf("got %#v", issues) + } +} diff --git a/pulls.go b/pulls.go index 3aa3392..6907074 100644 --- a/pulls.go +++ b/pulls.go @@ -77,11 +77,32 @@ func newPullService(c *Client) *PullService { // ListPullRequests returns all pull requests in a repository. func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string, filters ...any) ([]types.PullRequest, error) { + if pageOpts, ok := compatPullListPageOptions(filters...); ok { + page, err := s.listPage(ctx, owner, repo, pageOpts, filters...) + if err != nil { + return nil, err + } + return page.Items, nil + } return s.listAll(ctx, owner, repo, filters...) } // IterPullRequests returns an iterator over all pull requests in a repository. func (s *PullService) IterPullRequests(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.PullRequest, error] { + if pageOpts, ok := compatPullListPageOptions(filters...); ok { + return func(yield func(types.PullRequest, error) bool) { + page, err := s.listPage(ctx, owner, repo, pageOpts, filters...) + if err != nil { + yield(*new(types.PullRequest), err) + return + } + for _, item := range page.Items { + if !yield(item, nil) { + return + } + } + } + } return s.listIter(ctx, owner, repo, filters...) } @@ -390,6 +411,24 @@ func addCompatPullFilter(values url.Values, filter types.ListPullRequestsOption) } } +func compatPullListPageOptions(filters ...any) (ListOptions, bool) { + for _, filter := range filters { + switch v := filter.(type) { + case types.ListPullRequestsOption: + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + case *types.ListPullRequestsOption: + if v != nil { + if opts, ok := compatListOptions(v.Page, v.PageSize, v.Limit); ok { + return opts, true + } + } + } + } + return ListOptions{}, false +} + // ListReviewComments returns all comments on a pull request review. func (s *PullService) ListReviewComments(ctx context.Context, owner, repo string, index, reviewID int64) ([]types.PullReviewComment, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments", pathParams("owner", owner, "repo", repo, "index", int64String(index), "id", int64String(reviewID))) diff --git a/pulls_extra_test.go b/pulls_extra_test.go index 473b2f9..9424299 100644 --- a/pulls_extra_test.go +++ b/pulls_extra_test.go @@ -65,3 +65,39 @@ func TestPullService_CreatePullRequest_Good(t *testing.T) { t.Fatalf("got title=%q", pr.Title) } } + +func TestPullService_ListPullRequests_CompatPagination_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + w.Header().Set("X-Total-Count", "40") + json.NewEncoder(w).Encode([]types.PullRequest{{ID: 2, Title: "paged pull"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + prs, err := f.Pulls.ListPullRequests(context.Background(), "core", "go-forge", &types.ListPullRequestsOption{ + State: "open", + Page: 2, + PageSize: 25, + }) + if err != nil { + t.Fatal(err) + } + if len(prs) != 1 || prs[0].Title != "paged pull" { + t.Fatalf("got %#v", prs) + } +} diff --git a/types/content.go b/types/content.go index 8693d8f..a1f3630 100644 --- a/types/content.go +++ b/types/content.go @@ -36,6 +36,7 @@ type CreateFileOptions struct { Author *Identity `json:"author,omitempty"` BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used Committer *Identity `json:"committer,omitempty"` + Content string `json:"-"` // RFC compatibility alias for ContentBase64 ContentBase64 string `json:"content"` // content must be base64 encoded Dates *CommitDateOptions `json:"dates,omitempty"` Message string `json:"message,omitempty"` // message (optional) for the commit of this file. if not supplied, a default message will be used @@ -127,6 +128,7 @@ type UpdateFileOptions struct { Author *Identity `json:"author,omitempty"` BranchName string `json:"branch,omitempty"` // branch (optional) to base this file from. if not given, the default branch is used Committer *Identity `json:"committer,omitempty"` + Content string `json:"-"` // RFC compatibility alias for ContentBase64 ContentBase64 string `json:"content"` // content must be base64 encoded Dates *CommitDateOptions `json:"dates,omitempty"` FromPath string `json:"from_path,omitempty"` // from_path (optional) is the path of the original file which will be moved/renamed to the path in the URL diff --git a/types/content_compat.go b/types/content_compat.go new file mode 100644 index 0000000..0742dba --- /dev/null +++ b/types/content_compat.go @@ -0,0 +1,69 @@ +package types + +import json "github.com/goccy/go-json" + +type createFileOptionsCompat CreateFileOptions + +func (o CreateFileOptions) MarshalJSON() ([]byte, error) { + aux := createFileOptionsCompat(o) + if aux.ContentBase64 == "" && o.Content != "" { + aux.ContentBase64 = o.Content + } + return json.Marshal(aux) +} + +func (o *CreateFileOptions) UnmarshalJSON(data []byte) error { + var aux createFileOptionsCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = CreateFileOptions(aux) + if o.Content == "" { + o.Content = o.ContentBase64 + } + return nil +} + +type updateFileOptionsCompat UpdateFileOptions + +func (o UpdateFileOptions) MarshalJSON() ([]byte, error) { + aux := updateFileOptionsCompat(o) + if aux.ContentBase64 == "" && o.Content != "" { + aux.ContentBase64 = o.Content + } + return json.Marshal(aux) +} + +func (o *UpdateFileOptions) UnmarshalJSON(data []byte) error { + var aux updateFileOptionsCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = UpdateFileOptions(aux) + if o.Content == "" { + o.Content = o.ContentBase64 + } + return nil +} + +type changeFileOperationCompat ChangeFileOperation + +func (o ChangeFileOperation) MarshalJSON() ([]byte, error) { + aux := changeFileOperationCompat(o) + if aux.ContentBase64 == "" && o.Content != "" { + aux.ContentBase64 = o.Content + } + return json.Marshal(aux) +} + +func (o *ChangeFileOperation) UnmarshalJSON(data []byte) error { + var aux changeFileOperationCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = ChangeFileOperation(aux) + if o.Content == "" { + o.Content = o.ContentBase64 + } + return nil +} diff --git a/types/list_options_compat.go b/types/list_options_compat.go index 3a3d2d3..2ab0943 100644 --- a/types/list_options_compat.go +++ b/types/list_options_compat.go @@ -14,8 +14,11 @@ type ListIssueOption struct { Before *time.Time `json:"before,omitempty"` CreatedBy string `json:"created_by,omitempty"` Labels string `json:"labels,omitempty"` + Limit int `json:"limit,omitempty"` MentionedBy string `json:"mentioned_by,omitempty"` Milestones string `json:"milestones,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` Query string `json:"q,omitempty"` Sort string `json:"sort,omitempty"` State string `json:"state,omitempty"` @@ -30,8 +33,28 @@ type ListIssueOption struct { // opts := ListPullRequestsOption{State: "open"} type ListPullRequestsOption struct { Labels []int64 `json:"labels,omitempty"` + Limit int `json:"limit,omitempty"` Milestone int64 `json:"milestone,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` Poster string `json:"poster,omitempty"` Sort string `json:"sort,omitempty"` State string `json:"state,omitempty"` } + +// ListCommitsOption is a compatibility alias for repository commit list filters. +// +// Usage: +// +// opts := ListCommitsOption{Sha: "main"} +type ListCommitsOption struct { + Files *bool `json:"files,omitempty"` + Limit int `json:"limit,omitempty"` + Not string `json:"not,omitempty"` + Page int `json:"page,omitempty"` + PageSize int `json:"page_size,omitempty"` + Path string `json:"path,omitempty"` + Sha string `json:"sha,omitempty"` + Stat *bool `json:"stat,omitempty"` + Verification *bool `json:"verification,omitempty"` +} diff --git a/types/misc.go b/types/misc.go index 40ef026..7297757 100644 --- a/types/misc.go +++ b/types/misc.go @@ -30,6 +30,7 @@ type AddTimeOption struct { // // opts := ChangeFileOperation{Operation: "example"} type ChangeFileOperation struct { + Content string `json:"-"` // RFC compatibility alias for ContentBase64 ContentBase64 string `json:"content,omitempty"` // new or updated file content, must be base64 encoded FromPath string `json:"from_path,omitempty"` // old path of the file to move Operation string `json:"operation"` // indicates what to do with the file From 1ebe9054589c5b6beecd03d785300dd51db64919 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 19:50:06 +0100 Subject: [PATCH 05/15] feat(forge): single-page *Page wrappers returning *PagedResult across services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 10 services: Repos, Issues, Pulls, Commits, Branches, Orgs, Webhooks, Releases, Milestones, Labels — each gains List*Page methods accepting ListOptions and returning *PagedResult[T] - Existing List* methods unchanged (still fetch all pages) - service_pagination_extra_test.go: regression for new single-page entrypoints Verified: GOWORK=off go test ./... passes Co-Authored-By: Virgil --- branches.go | 6 + commits.go | 7 + issues.go | 11 + labels.go | 11 + milestones.go | 5 + orgs.go | 22 ++ pulls.go | 5 + releases.go | 6 + repos.go | 17 ++ service_pagination_extra_test.go | 348 +++++++++++++++++++++++++++++++ webhooks.go | 22 ++ 11 files changed, 460 insertions(+) create mode 100644 service_pagination_extra_test.go diff --git a/branches.go b/branches.go index e7d4320..af709a4 100644 --- a/branches.go +++ b/branches.go @@ -25,6 +25,12 @@ func newBranchService(c *Client) *BranchService { } } +// ListBranchesPage returns a single page of branches for a repository. +func (s *BranchService) ListBranchesPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Branch], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/branches", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Branch](ctx, s.client, path, nil, opts) +} + // ListBranches returns all branches for a repository. func (s *BranchService) ListBranches(ctx context.Context, owner, repo string) ([]types.Branch, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/branches", pathParams("owner", owner, "repo", repo)) diff --git a/commits.go b/commits.go index 00808b6..4bfbed3 100644 --- a/commits.go +++ b/commits.go @@ -110,6 +110,13 @@ func (s *CommitService) Get(ctx context.Context, params Params) (*types.Commit, return &out, nil } +// ListCommitsPage returns a single page of commits for a repository. +func (s *CommitService) ListCommitsPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.Commit], error) { + params := pathParams("owner", owner, "repo", repo) + path := ResolvePath(commitCollectionPath, params) + return ListPage[types.Commit](ctx, s.client, path, commitCompatListQuery(filters...), opts) +} + // ListCommits returns commits for a repository using RFC-compatible filters. func (s *CommitService) ListCommits(ctx context.Context, owner, repo string, filters ...any) ([]types.Commit, error) { params := pathParams("owner", owner, "repo", repo) diff --git a/issues.go b/issues.go index 01642bf..26839f8 100644 --- a/issues.go +++ b/issues.go @@ -295,6 +295,12 @@ func (s *IssueService) IterSearchIssues(ctx context.Context, opts SearchIssuesOp return ListIter[types.Issue](ctx, s.client, "/api/v1/repos/issues/search", opts.queryParams()) } +// ListIssuesPage returns a single page of issues in a repository. +func (s *IssueService) ListIssuesPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.Issue], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Issue](ctx, s.client, path, issueListQuery(filters...), opts) +} + // ListIssues returns all issues in a repository. func (s *IssueService) ListIssues(ctx context.Context, owner, repo string, filters ...any) ([]types.Issue, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) @@ -333,6 +339,11 @@ func (s *IssueService) ListRepoIssues(ctx context.Context, owner, repo string, f return s.ListIssues(ctx, owner, repo, filters...) } +// ListRepoIssuesPage returns a single page of issues in a repository. +func (s *IssueService) ListRepoIssuesPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.Issue], error) { + return s.ListIssuesPage(ctx, owner, repo, opts, filters...) +} + // IterRepoIssues returns an iterator over all issues in a repository. func (s *IssueService) IterRepoIssues(ctx context.Context, owner, repo string, filters ...any) iter.Seq2[types.Issue, error] { return s.IterIssues(ctx, owner, repo, filters...) diff --git a/labels.go b/labels.go index a7d66ee..23633a6 100644 --- a/labels.go +++ b/labels.go @@ -22,12 +22,23 @@ func newLabelService(c *Client) *LabelService { return &LabelService{client: c} } +// ListRepoLabelsPage returns a single page of labels for a repository. +func (s *LabelService) ListRepoLabelsPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Label], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Label](ctx, s.client, path, nil, opts) +} + // ListRepoLabels returns all labels for a repository. func (s *LabelService) ListRepoLabels(ctx context.Context, owner, repo string) ([]types.Label, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/labels", pathParams("owner", owner, "repo", repo)) return ListAll[types.Label](ctx, s.client, path, nil) } +// ListLabelsPage returns a single page of labels for a repository. +func (s *LabelService) ListLabelsPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Label], error) { + return s.ListRepoLabelsPage(ctx, owner, repo, opts) +} + // ListLabels returns all labels for a repository. func (s *LabelService) ListLabels(ctx context.Context, owner, repo string) ([]types.Label, error) { return s.ListRepoLabels(ctx, owner, repo) diff --git a/milestones.go b/milestones.go index 093ff67..1032396 100644 --- a/milestones.go +++ b/milestones.go @@ -53,6 +53,11 @@ func newMilestoneService(c *Client) *MilestoneService { return &MilestoneService{client: c} } +// ListMilestonesPage returns a single page of milestones for a repository. +func (s *MilestoneService) ListMilestonesPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...MilestoneListOptions) (*PagedResult[types.Milestone], error) { + return s.List(ctx, pathParams("owner", owner, "repo", repo), opts, filters...) +} + // ListMilestones returns all milestones for a repository. func (s *MilestoneService) ListMilestones(ctx context.Context, owner, repo string, filters ...MilestoneListOptions) ([]types.Milestone, error) { return s.ListAll(ctx, pathParams("owner", owner, "repo", repo), filters...) diff --git a/orgs.go b/orgs.go index be69fde..c617fcf 100644 --- a/orgs.go +++ b/orgs.go @@ -68,11 +68,22 @@ func (s *OrgService) DeleteOrg(ctx context.Context, org string) error { return s.Delete(ctx, pathParams("org", org)) } +// ListOrgsPage returns a single page of organisations. +func (s *OrgService) ListOrgsPage(ctx context.Context, opts ListOptions) (*PagedResult[types.Organization], error) { + return ListPage[types.Organization](ctx, s.client, "/api/v1/orgs", nil, opts) +} + // ListOrgs returns all organisations. func (s *OrgService) ListOrgs(ctx context.Context) ([]types.Organization, error) { return ListAll[types.Organization](ctx, s.client, "/api/v1/orgs", nil) } +// ListOrgTeamsPage returns a single page of teams in an organisation. +func (s *OrgService) ListOrgTeamsPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.Team], error) { + path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) + return ListPage[types.Team](ctx, s.client, path, nil, opts) +} + // ListOrgTeams returns all teams in an organisation. func (s *OrgService) ListOrgTeams(ctx context.Context, org string) ([]types.Team, error) { path := ResolvePath("/api/v1/orgs/{org}/teams", pathParams("org", org)) @@ -99,12 +110,23 @@ func (s *OrgService) CreateOrg(ctx context.Context, opts *types.CreateOrgOption) return &out, nil } +// ListMembersPage returns a single page of members of an organisation. +func (s *OrgService) ListMembersPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.User], error) { + path := ResolvePath("/api/v1/orgs/{org}/members", pathParams("org", org)) + return ListPage[types.User](ctx, s.client, path, nil, opts) +} + // ListMembers returns all members of an organisation. func (s *OrgService) ListMembers(ctx context.Context, org string) ([]types.User, error) { path := ResolvePath("/api/v1/orgs/{org}/members", pathParams("org", org)) return ListAll[types.User](ctx, s.client, path, nil) } +// ListOrgMembersPage returns a single page of members of an organisation. +func (s *OrgService) ListOrgMembersPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.User], error) { + return s.ListMembersPage(ctx, org, opts) +} + // ListOrgMembers returns all members of an organisation. func (s *OrgService) ListOrgMembers(ctx context.Context, org string) ([]types.User, error) { return s.ListMembers(ctx, org) diff --git a/pulls.go b/pulls.go index 6907074..6327abd 100644 --- a/pulls.go +++ b/pulls.go @@ -75,6 +75,11 @@ func newPullService(c *Client) *PullService { } } +// ListPullRequestsPage returns a single page of pull requests in a repository. +func (s *PullService) ListPullRequestsPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...any) (*PagedResult[types.PullRequest], error) { + return s.listPage(ctx, owner, repo, opts, filters...) +} + // ListPullRequests returns all pull requests in a repository. func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string, filters ...any) ([]types.PullRequest, error) { if pageOpts, ok := compatPullListPageOptions(filters...); ok { diff --git a/releases.go b/releases.go index c4e7c27..2eac1ce 100644 --- a/releases.go +++ b/releases.go @@ -100,6 +100,12 @@ func newReleaseService(c *Client) *ReleaseService { } } +// ListReleasesPage returns a single page of releases in a repository. +func (s *ReleaseService) ListReleasesPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...ReleaseListOptions) (*PagedResult[types.Release], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Release](ctx, s.client, path, releaseListQuery(filters...), opts) +} + // ListReleases returns all releases in a repository. func (s *ReleaseService) ListReleases(ctx context.Context, owner, repo string, filters ...ReleaseListOptions) ([]types.Release, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases", pathParams("owner", owner, "repo", repo)) diff --git a/repos.go b/repos.go index 0957e2b..a96261a 100644 --- a/repos.go +++ b/repos.go @@ -181,6 +181,12 @@ func (s *RepoService) CreateOrgRepoDeprecated(ctx context.Context, org string, o return &out, nil } +// ListOrgReposPage returns a single page of repositories for an organisation. +func (s *RepoService) ListOrgReposPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.Repository], error) { + path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) + return ListPage[types.Repository](ctx, s.client, path, nil, opts) +} + // ListOrgRepos returns all repositories for an organisation. func (s *RepoService) ListOrgRepos(ctx context.Context, org string) ([]types.Repository, error) { path := ResolvePath("/api/v1/orgs/{org}/repos", pathParams("org", org)) @@ -193,6 +199,17 @@ func (s *RepoService) IterOrgRepos(ctx context.Context, org string) iter.Seq2[ty return ListIter[types.Repository](ctx, s.client, path, nil) } +// ListCurrentUserReposPage returns a single page of repositories for the authenticated user. +func (s *RepoService) ListCurrentUserReposPage(ctx context.Context, opts ListOptions) (*PagedResult[types.Repository], error) { + return ListPage[types.Repository](ctx, s.client, "/api/v1/user/repos", nil, opts) +} + +// ListUserReposPage returns a single page of repositories for a user. +func (s *RepoService) ListUserReposPage(ctx context.Context, username string, opts ListOptions) (*PagedResult[types.Repository], error) { + path := ResolvePath("/api/v1/users/{username}/repos", pathParams("username", username)) + return ListPage[types.Repository](ctx, s.client, path, nil, opts) +} + // ListUserRepos returns all repositories for a user. // When username is omitted, it returns repositories for the authenticated user. func (s *RepoService) ListUserRepos(ctx context.Context, username ...string) ([]types.Repository, error) { diff --git a/service_pagination_extra_test.go b/service_pagination_extra_test.go new file mode 100644 index 0000000..c367c60 --- /dev/null +++ b/service_pagination_extra_test.go @@ -0,0 +1,348 @@ +package forge + +import ( + "context" + json "github.com/goccy/go-json" + "net/http" + "net/http/httptest" + "testing" + + "dappco.re/go/core/forge/types" +) + +func TestRepoService_ListOrgReposPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/repos" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "25" { + t.Errorf("got limit=%q, want %q", got, "25") + } + w.Header().Set("X-Total-Count", "51") + json.NewEncoder(w).Encode([]types.Repository{{Name: "go-forge"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Repos.ListOrgReposPage(context.Background(), "core", ListOptions{Page: 2, PageSize: 25}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 51 || len(page.Items) != 1 || page.Items[0].Name != "go-forge" { + t.Fatalf("got %#v", page) + } +} + +func TestRepoService_ListUserReposPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/Virgil/repos" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Errorf("got limit=%q, want %q", got, "10") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Repository{{Name: "go-user"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Repos.ListUserReposPage(context.Background(), "Virgil", ListOptions{Page: 1, PageSize: 10}) + if err != nil { + t.Fatal(err) + } + if page.TotalCount != 1 || len(page.Items) != 1 || page.Items[0].Name != "go-user" { + t.Fatalf("got %#v", page) + } +} + +func TestIssueService_ListRepoIssuesPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "3" { + t.Errorf("got page=%q, want %q", got, "3") + } + if got := r.URL.Query().Get("limit"); got != "15" { + t.Errorf("got limit=%q, want %q", got, "15") + } + w.Header().Set("X-Total-Count", "42") + json.NewEncoder(w).Encode([]types.Issue{{ID: 7, Title: "bug"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Issues.ListRepoIssuesPage(context.Background(), "core", "go-forge", ListOptions{Page: 3, PageSize: 15}, &types.ListIssueOption{State: "open"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 3 || page.TotalCount != 42 || len(page.Items) != 1 || page.Items[0].Title != "bug" { + t.Fatalf("got %#v", page) + } +} + +func TestPullService_ListPullRequestsPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "20" { + t.Errorf("got limit=%q, want %q", got, "20") + } + w.Header().Set("X-Total-Count", "21") + json.NewEncoder(w).Encode([]types.PullRequest{{ID: 9, Title: "feature"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Pulls.ListPullRequestsPage(context.Background(), "core", "go-forge", ListOptions{Page: 2, PageSize: 20}, &types.ListPullRequestsOption{State: "open"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 21 || len(page.Items) != 1 || page.Items[0].Title != "feature" { + t.Fatalf("got %#v", page) + } +} + +func TestCommitService_ListCommitsPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/commits" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("sha"); got != "main" { + t.Errorf("got sha=%q, want %q", got, "main") + } + if got := r.URL.Query().Get("page"); got != "4" { + t.Errorf("got page=%q, want %q", got, "4") + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Errorf("got limit=%q, want %q", got, "5") + } + w.Header().Set("X-Total-Count", "17") + json.NewEncoder(w).Encode([]types.Commit{{SHA: "abc123"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Commits.ListCommitsPage(context.Background(), "core", "go-forge", ListOptions{Page: 4, PageSize: 5}, &types.ListCommitsOption{Sha: "main"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 4 || page.TotalCount != 17 || len(page.Items) != 1 || page.Items[0].SHA != "abc123" { + t.Fatalf("got %#v", page) + } +} + +func TestBranchService_ListBranchesPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/branches" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "30" { + t.Errorf("got limit=%q, want %q", got, "30") + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Branch{{Name: "main"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Branches.ListBranchesPage(context.Background(), "core", "go-forge", ListOptions{Page: 1, PageSize: 30}) + if err != nil { + t.Fatal(err) + } + if page.TotalCount != 1 || len(page.Items) != 1 || page.Items[0].Name != "main" { + t.Fatalf("got %#v", page) + } +} + +func TestOrgService_ListOrgTeamsPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/orgs/core/teams" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Errorf("got limit=%q, want %q", got, "10") + } + w.Header().Set("X-Total-Count", "11") + json.NewEncoder(w).Encode([]types.Team{{ID: 1, Name: "maintainers"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Orgs.ListOrgTeamsPage(context.Background(), "core", ListOptions{Page: 2, PageSize: 10}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 11 || len(page.Items) != 1 || page.Items[0].Name != "maintainers" { + t.Fatalf("got %#v", page) + } +} + +func TestWebhookService_ListRepoHooksPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/hooks" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Errorf("got limit=%q, want %q", got, "5") + } + w.Header().Set("X-Total-Count", "6") + json.NewEncoder(w).Encode([]types.Hook{{ID: 1, Type: "forgejo"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Webhooks.ListRepoHooksPage(context.Background(), "core", "go-forge", ListOptions{Page: 1, PageSize: 5}) + if err != nil { + t.Fatal(err) + } + if page.TotalCount != 6 || len(page.Items) != 1 || page.Items[0].Type != "forgejo" { + t.Fatalf("got %#v", page) + } +} + +func TestReleaseService_ListReleasesPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/releases" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("draft"); got != "true" { + t.Errorf("got draft=%q, want %q", got, "true") + } + if got := r.URL.Query().Get("q"); got != "v1" { + t.Errorf("got q=%q, want %q", got, "v1") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Errorf("got limit=%q, want %q", got, "10") + } + w.Header().Set("X-Total-Count", "12") + json.NewEncoder(w).Encode([]types.Release{{ID: 1, TagName: "v1.0.0"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Releases.ListReleasesPage(context.Background(), "core", "go-forge", ListOptions{Page: 2, PageSize: 10}, ReleaseListOptions{Draft: true, Query: "v1"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 12 || len(page.Items) != 1 || page.Items[0].TagName != "v1.0.0" { + t.Fatalf("got %#v", page) + } +} + +func TestMilestoneService_ListMilestonesPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/milestones" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Errorf("got state=%q, want %q", got, "open") + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("got page=%q, want %q", got, "2") + } + if got := r.URL.Query().Get("limit"); got != "10" { + t.Errorf("got limit=%q, want %q", got, "10") + } + w.Header().Set("X-Total-Count", "11") + json.NewEncoder(w).Encode([]types.Milestone{{ID: 2, Title: "Sprint"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Milestones.ListMilestonesPage(context.Background(), "core", "go-forge", ListOptions{Page: 2, PageSize: 10}, MilestoneListOptions{State: "open"}) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.TotalCount != 11 || len(page.Items) != 1 || page.Items[0].Title != "Sprint" { + t.Fatalf("got %#v", page) + } +} + +func TestLabelService_ListLabelsPage_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/repos/core/go-forge/labels" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("got page=%q, want %q", got, "1") + } + if got := r.URL.Query().Get("limit"); got != "8" { + t.Errorf("got limit=%q, want %q", got, "8") + } + w.Header().Set("X-Total-Count", "8") + json.NewEncoder(w).Encode([]types.Label{{ID: 1, Name: "bug"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Labels.ListLabelsPage(context.Background(), "core", "go-forge", ListOptions{Page: 1, PageSize: 8}) + if err != nil { + t.Fatal(err) + } + if page.TotalCount != 8 || len(page.Items) != 1 || page.Items[0].Name != "bug" { + t.Fatalf("got %#v", page) + } +} diff --git a/webhooks.go b/webhooks.go index bd5ee9f..23394d5 100644 --- a/webhooks.go +++ b/webhooks.go @@ -26,12 +26,23 @@ func newWebhookService(c *Client) *WebhookService { } } +// ListHooksPage returns a single page of webhooks for a repository. +func (s *WebhookService) ListHooksPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Hook], error) { + path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks", pathParams("owner", owner, "repo", repo)) + return ListPage[types.Hook](ctx, s.client, path, nil, opts) +} + // ListHooks returns all webhooks for a repository. func (s *WebhookService) ListHooks(ctx context.Context, owner, repo string) ([]types.Hook, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/hooks", pathParams("owner", owner, "repo", repo)) return ListAll[types.Hook](ctx, s.client, path, nil) } +// ListRepoHooksPage returns a single page of webhooks for a repository. +func (s *WebhookService) ListRepoHooksPage(ctx context.Context, owner, repo string, opts ListOptions) (*PagedResult[types.Hook], error) { + return s.ListHooksPage(ctx, owner, repo, opts) +} + // ListRepoHooks returns all webhooks for a repository. func (s *WebhookService) ListRepoHooks(ctx context.Context, owner, repo string) ([]types.Hook, error) { return s.ListHooks(ctx, owner, repo) @@ -126,6 +137,11 @@ func (s *WebhookService) ListUserHooks(ctx context.Context) ([]types.Hook, error return ListAll[types.Hook](ctx, s.client, "/api/v1/user/hooks", nil) } +// ListUserHooksPage returns a single page of webhooks for the authenticated user. +func (s *WebhookService) ListUserHooksPage(ctx context.Context, opts ListOptions) (*PagedResult[types.Hook], error) { + return ListPage[types.Hook](ctx, s.client, "/api/v1/user/hooks", nil, opts) +} + // IterUserHooks returns an iterator over all webhooks for the authenticated user. func (s *WebhookService) IterUserHooks(ctx context.Context) iter.Seq2[types.Hook, error] { return ListIter[types.Hook](ctx, s.client, "/api/v1/user/hooks", nil) @@ -172,6 +188,12 @@ func (s *WebhookService) ListOrgHooks(ctx context.Context, org string) ([]types. return ListAll[types.Hook](ctx, s.client, path, nil) } +// ListOrgHooksPage returns a single page of webhooks for an organisation. +func (s *WebhookService) ListOrgHooksPage(ctx context.Context, org string, opts ListOptions) (*PagedResult[types.Hook], error) { + path := ResolvePath("/api/v1/orgs/{org}/hooks", pathParams("org", org)) + return ListPage[types.Hook](ctx, s.client, path, nil, opts) +} + // IterOrgHooks returns an iterator over all webhooks for an organisation. func (s *WebhookService) IterOrgHooks(ctx context.Context, org string) iter.Seq2[types.Hook, error] { path := ResolvePath("/api/v1/orgs/{org}/hooks", pathParams("org", org)) From 8e4f22878b98805916917f93a69c8b21d7726f77 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 19:55:06 +0100 Subject: [PATCH 06/15] fix(forge): GetUserByID uses /users/search?uid= instead of username endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users.go: numeric ID lookup now routes through Forgejo's supported /api/v1/users/search?uid= and returns a 404-style APIError when no match. Previously silently hit the username endpoint with the ID as the username. - users_test.go: success + not-found coverage - pulls_test.go: regression for MergePullRequestOption.MergeStyle → Do Verified: GOWORK=off go test ./... passes Co-Authored-By: Virgil --- pulls_test.go | 37 ++++++++++++++++++++++++++++ users.go | 24 +++++++++++++++++- users_test.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/pulls_test.go b/pulls_test.go index 7569d3f..001ad4c 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -407,6 +407,43 @@ func TestPullService_Merge_Good(t *testing.T) { } } +func TestPullService_MergePullRequest_CompatMergeStyle_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/pulls/7/merge" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if got := body["Do"]; got != "squash" { + t.Fatalf("got Do=%v, want squash", got) + } + if got := body["MergeMessageField"]; got != "PR: Add feature" { + t.Fatalf("got MergeMessageField=%v, want %q", got, "PR: Add feature") + } + if _, ok := body["MergeStyle"]; ok { + t.Fatalf("did not expect MergeStyle in request body: %#v", body) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + err := f.Pulls.MergePullRequest(context.Background(), "core", "go-forge", 7, &types.MergePullRequestOption{ + MergeMessageField: "PR: Add feature", + MergeStyle: "squash", + }) + if err != nil { + t.Fatal(err) + } +} + func TestPullService_Merge_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) diff --git a/users.go b/users.go index 49b0567..fdc8b98 100644 --- a/users.go +++ b/users.go @@ -88,7 +88,29 @@ func newUserService(c *Client) *UserService { // GetUserByID returns a user by numeric ID. func (s *UserService) GetUserByID(ctx context.Context, id int64) (*types.User, error) { - return s.GetUserByUsername(ctx, strconv.FormatInt(id, 10)) + if id < 1 { + return nil, &APIError{ + StatusCode: http.StatusNotFound, + Message: "user not found", + URL: "/api/v1/users/search", + } + } + + result, err := s.SearchUsersPage(ctx, "", ListOptions{Page: 1, PageSize: 1}, UserSearchOptions{UID: id}) + if err != nil { + return nil, err + } + for i := range result.Items { + if result.Items[i].ID == id { + return &result.Items[i], nil + } + } + + return nil, &APIError{ + StatusCode: http.StatusNotFound, + Message: "user not found", + URL: "/api/v1/users/search", + } } // GetUserByUsername returns a user by username. diff --git a/users_test.go b/users_test.go index 9fe78e2..bc4f2b3 100644 --- a/users_test.go +++ b/users_test.go @@ -55,6 +55,73 @@ func TestUserService_GetCurrent_Good(t *testing.T) { } } +func TestUserService_GetUserByID_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("uid"); got != "42" { + t.Errorf("wrong uid: %s", got) + } + if got := r.URL.Query().Get("page"); got != "1" { + t.Errorf("wrong page: %s", got) + } + if got := r.URL.Query().Get("limit"); got != "1" { + t.Errorf("wrong limit: %s", got) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{ + {ID: 42, UserName: "alice"}, + }, + "ok": true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + user, err := f.Users.GetUserByID(context.Background(), 42) + if err != nil { + t.Fatal(err) + } + if user.ID != 42 || user.UserName != "alice" { + t.Fatalf("got %#v", user) + } +} + +func TestUserService_GetUserByID_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("uid"); got != "42" { + t.Errorf("wrong uid: %s", got) + } + w.Header().Set("X-Total-Count", "0") + json.NewEncoder(w).Encode(map[string]any{ + "data": []*types.User{}, + "ok": true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + _, err := f.Users.GetUserByID(context.Background(), 42) + if !IsNotFound(err) { + t.Fatalf("expected not found error, got %v", err) + } +} + func TestUserService_GetSettings_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { From d1a3428c8535e4a84e05ea02129b9ec9224e95c0 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 20:03:33 +0100 Subject: [PATCH 07/15] feat(forge): Labels field accepts IDs or names per RFC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types/issue.go + pr.go: CreateIssueOption.Labels + EditPullRequestOption.Labels accept any — either []int64 IDs or []string names (RFC shape) - types/label_compat.go: helper for label normalisation + direct decode test coverage - cmd/forgegen/parser.go: codegen preserves the same compatibility rule going forward - issues_extra_test.go + pulls_extra_test.go: RFC-shaped service-call coverage Verified: GOWORK=off go test ./... passes Co-Authored-By: Virgil --- cmd/forgegen/parser.go | 17 ++++++++ cmd/forgegen/parser_test.go | 32 +++++++++++++++ issues_extra_test.go | 36 +++++++++++++++++ pulls_extra_test.go | 38 ++++++++++++++++++ types/issue.go | 2 +- types/label_compat.go | 80 +++++++++++++++++++++++++++++++++++++ types/label_compat_test.go | 36 +++++++++++++++++ types/pr.go | 4 +- 8 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 types/label_compat.go create mode 100644 types/label_compat_test.go diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go index a73a38f..1731e42 100644 --- a/cmd/forgegen/parser.go +++ b/cmd/forgegen/parser.go @@ -171,11 +171,28 @@ func ExtractTypes(spec *Spec) map[string]*GoType { slices.SortFunc(gt.Fields, func(a, b GoField) int { return cmp.Compare(a.GoName, b.GoName) }) + applyRFCCompatOverrides(gt) result[name] = gt } return result } +func applyRFCCompatOverrides(gt *GoType) { + if gt == nil { + return + } + + switch gt.Name { + case "CreateIssueOption", "CreatePullRequestOption", "EditPullRequestOption": + for i := range gt.Fields { + if gt.Fields[i].JSONName == "labels" { + gt.Fields[i].GoType = "any" + gt.Fields[i].Comment = "list of label ids or names (RFC compatibility)" + } + } + } +} + func definitionAliasType(def SchemaDefinition, defs map[string]SchemaDefinition) (string, bool) { if def.Ref != "" { return refName(def.Ref), true diff --git a/cmd/forgegen/parser_test.go b/cmd/forgegen/parser_test.go index e88da5f..739866e 100644 --- a/cmd/forgegen/parser_test.go +++ b/cmd/forgegen/parser_test.go @@ -167,3 +167,35 @@ func TestParser_PrimitiveAndCollectionAliases_Good(t *testing.T) { }) } } + +func TestParser_RFCLabelCompatibility_Good(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + types := ExtractTypes(spec) + cases := []string{ + "CreateIssueOption", + "CreatePullRequestOption", + "EditPullRequestOption", + } + + for _, typeName := range cases { + t.Run(typeName, func(t *testing.T) { + gt, ok := types[typeName] + if !ok { + t.Fatalf("type %q not found", typeName) + } + for _, field := range gt.Fields { + if field.JSONName == "labels" { + if field.GoType != "any" { + t.Fatalf("labels field: got %q, want any", field.GoType) + } + return + } + } + t.Fatalf("labels field not found on %s", typeName) + }) + } +} diff --git a/issues_extra_test.go b/issues_extra_test.go index 64d42ba..f0e9a99 100644 --- a/issues_extra_test.go +++ b/issues_extra_test.go @@ -62,6 +62,42 @@ func TestIssueService_CreateIssue_Good(t *testing.T) { } } +func TestIssueService_CreateIssue_LabelNamesCompat_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/issues" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateIssueOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + labels, ok := body.Labels.([]string) + if !ok { + t.Fatalf("expected []string labels, got %T", body.Labels) + } + if len(labels) != 1 || labels[0] != "enhancement" { + t.Fatalf("unexpected labels: %#v", labels) + } + json.NewEncoder(w).Encode(types.Issue{ID: 2, Index: 2, Title: "label issue"}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issue, err := f.Issues.CreateIssue(context.Background(), "core", "go-forge", &types.CreateIssueOption{ + Title: "label issue", + Labels: []string{"enhancement"}, + }) + if err != nil { + t.Fatal(err) + } + if issue.Title != "label issue" { + t.Fatalf("got title=%q", issue.Title) + } +} + func TestIssueService_ListRepoIssues_CompatPagination_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/pulls_extra_test.go b/pulls_extra_test.go index 9424299..128787e 100644 --- a/pulls_extra_test.go +++ b/pulls_extra_test.go @@ -66,6 +66,44 @@ func TestPullService_CreatePullRequest_Good(t *testing.T) { } } +func TestPullService_CreatePullRequest_LabelNamesCompat_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/pulls" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreatePullRequestOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + labels, ok := body.Labels.([]string) + if !ok { + t.Fatalf("expected []string labels, got %T", body.Labels) + } + if len(labels) != 1 || labels[0] != "feature" { + t.Fatalf("unexpected labels: %#v", labels) + } + json.NewEncoder(w).Encode(types.PullRequest{ID: 2, Title: "labelled pr", Index: 2}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + pr, err := f.Pulls.CreatePullRequest(context.Background(), "core", "go-forge", &types.CreatePullRequestOption{ + Title: "labelled pr", + Base: "main", + Head: "feature", + Labels: []string{"feature"}, + }) + if err != nil { + t.Fatal(err) + } + if pr.Title != "labelled pr" { + t.Fatalf("got title=%q", pr.Title) + } +} + func TestPullService_ListPullRequests_CompatPagination_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/types/issue.go b/types/issue.go index b04b9e4..f71e782 100644 --- a/types/issue.go +++ b/types/issue.go @@ -25,7 +25,7 @@ type CreateIssueOption struct { Body string `json:"body,omitempty"` Closed bool `json:"closed,omitempty"` Deadline time.Time `json:"due_date,omitempty"` - Labels []int64 `json:"labels,omitempty"` // list of label ids + Labels any `json:"labels,omitempty"` // list of label ids or names (RFC compatibility) Milestone int64 `json:"milestone,omitempty"` // milestone id Ref string `json:"ref,omitempty"` Title string `json:"title"` diff --git a/types/label_compat.go b/types/label_compat.go new file mode 100644 index 0000000..00bd2d0 --- /dev/null +++ b/types/label_compat.go @@ -0,0 +1,80 @@ +package types + +import json "github.com/goccy/go-json" + +type createIssueOptionCompat CreateIssueOption + +func (o *CreateIssueOption) UnmarshalJSON(data []byte) error { + var aux createIssueOptionCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = CreateIssueOption(aux) + o.Labels = normaliseLabelRefs(o.Labels) + return nil +} + +type createPullRequestOptionCompat CreatePullRequestOption + +func (o *CreatePullRequestOption) UnmarshalJSON(data []byte) error { + var aux createPullRequestOptionCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = CreatePullRequestOption(aux) + o.Labels = normaliseLabelRefs(o.Labels) + return nil +} + +type editPullRequestOptionCompat EditPullRequestOption + +func (o *EditPullRequestOption) UnmarshalJSON(data []byte) error { + var aux editPullRequestOptionCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = EditPullRequestOption(aux) + o.Labels = normaliseLabelRefs(o.Labels) + return nil +} + +func normaliseLabelRefs(v any) any { + items, ok := v.([]any) + if !ok { + return v + } + if len(items) == 0 { + return []string{} + } + + strs := make([]string, 0, len(items)) + for _, item := range items { + s, ok := item.(string) + if !ok { + strs = nil + break + } + strs = append(strs, s) + } + if strs != nil { + return strs + } + + ints := make([]int64, 0, len(items)) + for _, item := range items { + switch x := item.(type) { + case float64: + if float64(int64(x)) != x { + return v + } + ints = append(ints, int64(x)) + case int64: + ints = append(ints, x) + case int: + ints = append(ints, int64(x)) + default: + return v + } + } + return ints +} diff --git a/types/label_compat_test.go b/types/label_compat_test.go new file mode 100644 index 0000000..fe6c56f --- /dev/null +++ b/types/label_compat_test.go @@ -0,0 +1,36 @@ +package types + +import ( + json "github.com/goccy/go-json" + "testing" +) + +func TestCreateIssueOption_Unmarshal_LabelNamesCompat_Good(t *testing.T) { + var opts CreateIssueOption + if err := json.Unmarshal([]byte(`{"title":"issue","labels":["enhancement","bug"]}`), &opts); err != nil { + t.Fatal(err) + } + + labels, ok := opts.Labels.([]string) + if !ok { + t.Fatalf("expected []string labels, got %T", opts.Labels) + } + if len(labels) != 2 || labels[0] != "enhancement" || labels[1] != "bug" { + t.Fatalf("unexpected labels: %#v", labels) + } +} + +func TestCreatePullRequestOption_Unmarshal_LabelIDsCompat_Good(t *testing.T) { + var opts CreatePullRequestOption + if err := json.Unmarshal([]byte(`{"title":"pr","labels":[1,2]}`), &opts); err != nil { + t.Fatal(err) + } + + labels, ok := opts.Labels.([]int64) + if !ok { + t.Fatalf("expected []int64 labels, got %T", opts.Labels) + } + if len(labels) != 2 || labels[0] != 1 || labels[1] != 2 { + t.Fatalf("unexpected labels: %#v", labels) + } +} diff --git a/types/pr.go b/types/pr.go index 7204103..8a395a5 100644 --- a/types/pr.go +++ b/types/pr.go @@ -16,7 +16,7 @@ type CreatePullRequestOption struct { Body string `json:"body,omitempty"` Deadline time.Time `json:"due_date,omitempty"` Head string `json:"head,omitempty"` - Labels []int64 `json:"labels,omitempty"` + Labels any `json:"labels,omitempty"` // list of label ids or names (RFC compatibility) Milestone int64 `json:"milestone,omitempty"` Title string `json:"title,omitempty"` } @@ -64,7 +64,7 @@ type EditPullRequestOption struct { Base string `json:"base,omitempty"` Body string `json:"body,omitempty"` Deadline time.Time `json:"due_date,omitempty"` - Labels []int64 `json:"labels,omitempty"` + Labels any `json:"labels,omitempty"` // list of label ids or names (RFC compatibility) Milestone int64 `json:"milestone,omitempty"` RemoveDeadline bool `json:"unset_due_date,omitempty"` State string `json:"state,omitempty"` From e68bf16503d3049845ca22a151ce2bf07f664502 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 14 Apr 2026 20:09:38 +0100 Subject: [PATCH 08/15] feat(forge): multipart upload Accept+rate-limit + MergePullRequestOption JSON compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client.go: multipart uploads send Accept: application/json and update rate-limit state from response headers on both success and API error paths - types/misc_compat.go: JSON compat for MergePullRequestOption — MergeStyle alias maps to Forgejo Do field during decode (works outside PullService too) - Coverage: client_test for multipart + rate limits, misc_compat_test for MergeStyle → Do mapping Verified: GOWORK=off go test ./... passes Co-Authored-By: Virgil --- client.go | 3 ++ client_test.go | 76 +++++++++++++++++++++++++++++++++++++++ types/misc_compat.go | 25 +++++++++++++ types/misc_compat_test.go | 46 ++++++++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 types/misc_compat.go create mode 100644 types/misc_compat_test.go diff --git a/client.go b/client.go index dbc1948..50775f0 100644 --- a/client.go +++ b/client.go @@ -473,6 +473,7 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s if auth := c.authorizationHeader(); auth != "" { req.Header.Set("Authorization", auth) } + req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", writer.FormDataContentType()) if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) @@ -484,6 +485,8 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s } defer resp.Body.Close() + c.updateRateLimit(resp) + if resp.StatusCode >= 400 { return c.parseError(resp, path) } diff --git a/client_test.go b/client_test.go index 3b41be0..84b5a10 100644 --- a/client_test.go +++ b/client_test.go @@ -6,6 +6,7 @@ import ( json "github.com/goccy/go-json" "net/http" "net/http/httptest" + "strings" "testing" core "dappco.re/go/core" @@ -139,6 +140,81 @@ func TestClient_GetRaw_Good(t *testing.T) { } } +func TestClient_PostMultipartJSON_RateLimit_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 got := r.Header.Get("Accept"); got != "application/json" { + t.Errorf("got Accept=%q, want %q", got, "application/json") + } + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Errorf("got Authorization=%q", got) + } + if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "multipart/form-data; boundary=") { + t.Errorf("got Content-Type=%q", got) + } + w.Header().Set("X-RateLimit-Limit", "70") + w.Header().Set("X-RateLimit-Remaining", "69") + w.Header().Set("X-RateLimit-Reset", "1700000003") + json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": "artifact"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + var out map[string]any + err := c.postMultipartJSON( + context.Background(), + "/api/v1/repos/core/go-forge/releases/1/assets", + map[string]string{"name": "artifact"}, + nil, + "attachment", + "artifact.tar.gz", + strings.NewReader("payload"), + &out, + ) + if err != nil { + t.Fatal(err) + } + if out["name"] != "artifact" { + t.Fatalf("got out=%#v", out) + } + rl := c.RateLimit() + if rl.Limit != 70 || rl.Remaining != 69 || rl.Reset != 1700000003 { + t.Fatalf("unexpected rate limit: %+v", rl) + } +} + +func TestClient_PostMultipartJSON_RateLimitOnError_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-RateLimit-Limit", "80") + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", "1700000004") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"message": "forbidden"}) + })) + defer srv.Close() + + c := NewClient(srv.URL, "test-token") + err := c.postMultipartJSON( + context.Background(), + "/api/v1/repos/core/go-forge/releases/1/assets", + nil, + nil, + "attachment", + "artifact.tar.gz", + strings.NewReader("payload"), + nil, + ) + if !IsForbidden(err) { + t.Fatalf("expected forbidden, got %v", err) + } + rl := c.RateLimit() + if rl.Limit != 80 || rl.Remaining != 0 || rl.Reset != 1700000004 { + t.Fatalf("unexpected rate limit: %+v", rl) + } +} + func TestClient_ServerError_Bad(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) diff --git a/types/misc_compat.go b/types/misc_compat.go new file mode 100644 index 0000000..a6139ab --- /dev/null +++ b/types/misc_compat.go @@ -0,0 +1,25 @@ +package types + +import json "github.com/goccy/go-json" + +type mergePullRequestOptionCompat MergePullRequestOption + +func (o MergePullRequestOption) MarshalJSON() ([]byte, error) { + aux := mergePullRequestOptionCompat(o) + if aux.Do == "" && o.MergeStyle != "" { + aux.Do = o.MergeStyle + } + return json.Marshal(aux) +} + +func (o *MergePullRequestOption) UnmarshalJSON(data []byte) error { + var aux mergePullRequestOptionCompat + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + *o = MergePullRequestOption(aux) + if o.MergeStyle == "" { + o.MergeStyle = o.Do + } + return nil +} diff --git a/types/misc_compat_test.go b/types/misc_compat_test.go new file mode 100644 index 0000000..895d8cd --- /dev/null +++ b/types/misc_compat_test.go @@ -0,0 +1,46 @@ +package types + +import ( + json "github.com/goccy/go-json" + "testing" +) + +func TestMergePullRequestOption_MarshalJSON_CompatMergeStyle_Good(t *testing.T) { + data, err := json.Marshal(MergePullRequestOption{ + MergeMessageField: "PR: Add feature", + MergeStyle: "squash", + }) + if err != nil { + t.Fatal(err) + } + + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Fatal(err) + } + if got := body["Do"]; got != "squash" { + t.Fatalf("got Do=%v, want squash", got) + } + if _, ok := body["MergeStyle"]; ok { + t.Fatalf("did not expect MergeStyle in request body: %#v", body) + } + if got := body["MergeMessageField"]; got != "PR: Add feature" { + t.Fatalf("got MergeMessageField=%v, want %q", got, "PR: Add feature") + } +} + +func TestMergePullRequestOption_UnmarshalJSON_CompatMergeStyle_Good(t *testing.T) { + var opts MergePullRequestOption + if err := json.Unmarshal([]byte(`{"Do":"rebase","MergeMessageField":"ready"}`), &opts); err != nil { + t.Fatal(err) + } + if opts.Do != "rebase" { + t.Fatalf("got Do=%q, want %q", opts.Do, "rebase") + } + if opts.MergeStyle != "rebase" { + t.Fatalf("got MergeStyle=%q, want %q", opts.MergeStyle, "rebase") + } + if opts.MergeMessageField != "ready" { + t.Fatalf("got MergeMessageField=%q, want %q", opts.MergeMessageField, "ready") + } +} From a1b77c5642701d5a946c65be8cc04e477034fa42 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Apr 2026 20:10:04 +0100 Subject: [PATCH 09/15] chore(go-forge): annotate goccy/go-json import in client.go per AX-6 client.go uses goccy/go-json as a faster drop-in for encoding/json. Added `// Note:` annotation documenting that no core.* equivalent exists for the JSON-decoder performance profile. Closes tasks.lthn.sh/view.php?id=740 Co-authored-by: Codex --- client.go | 1 + 1 file changed, 1 insertion(+) diff --git a/client.go b/client.go index 50775f0..1b3560e 100644 --- a/client.go +++ b/client.go @@ -3,6 +3,7 @@ package forge import ( "bytes" "context" + // Note: goccy/go-json — faster drop-in encoding/json replacement; no core equivalent for JSON-decoder performance profile. json "github.com/goccy/go-json" "mime/multipart" "net/http" From 54ec673bcfbff9c8673ed00b64714f01ff474c0f Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Apr 2026 20:24:53 +0100 Subject: [PATCH 10/15] chore(go-forge): annotate banned imports in client.go per AX-6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added `// Note:` annotations on bytes (body buffering), io (streaming upload), mime/multipart (file upload writer), net/http (HTTP client primitives). All intrinsic to the HTTP forge client layer — no core.* equivalents. Closes tasks.lthn.sh/view.php?id=739 Co-authored-by: Codex --- client.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client.go b/client.go index 1b3560e..3702f32 100644 --- a/client.go +++ b/client.go @@ -1,15 +1,19 @@ package forge import ( + // Note: bytes.Buffer/Reader for body buffering. "bytes" "context" // Note: goccy/go-json — faster drop-in encoding/json replacement; no core equivalent for JSON-decoder performance profile. json "github.com/goccy/go-json" + // Note: multipart writer for file uploads; no core equivalent. "mime/multipart" + // Note: HTTP client primitives; no core.Client equivalent. "net/http" "net/url" "strconv" + // Note: io.Reader/Writer for streaming upload. goio "io" core "dappco.re/go/core" From fb834a5010f744bce63533abf8882bf775dddc09 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Apr 2026 21:51:08 +0100 Subject: [PATCH 11/15] chore(go-forge): migrate module path + stale io dep per AX-6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - go.mod line 1: module dappco.re/go/core/forge → dappco.re/go/forge - go.mod require: dappco.re/go/core/io v0.4.1 → dappco.re/go/io v0.4.1 - 56 *.go files: self-imports + core/io imports rewritten Pre-existing go.work still references old layout — unrelated gap, separate ticket. Closes tasks.lthn.sh/view.php?id=738 Co-authored-by: Codex --- actions.go | 2 +- actions_test.go | 2 +- activitypub.go | 2 +- activitypub_test.go | 2 +- admin.go | 2 +- admin_test.go | 2 +- branches.go | 2 +- branches_extra_test.go | 2 +- branches_test.go | 2 +- cmd/forgegen/generator.go | 2 +- cmd/forgegen/generator_test.go | 2 +- cmd/forgegen/main_test.go | 2 +- cmd/forgegen/parser.go | 2 +- commits.go | 2 +- commits_extra_test.go | 2 +- commits_test.go | 2 +- config.go | 2 +- config_test.go | 2 +- contents.go | 2 +- contents_test.go | 2 +- forge_test.go | 2 +- go.mod | 4 ++-- issues.go | 2 +- issues_extra_test.go | 2 +- issues_test.go | 2 +- labels.go | 2 +- labels_test.go | 2 +- milestones.go | 2 +- milestones_test.go | 2 +- misc.go | 2 +- misc_test.go | 2 +- notifications.go | 2 +- notifications_test.go | 2 +- orgs.go | 2 +- orgs_extra_test.go | 2 +- orgs_test.go | 2 +- packages.go | 2 +- packages_test.go | 2 +- pulls.go | 2 +- pulls_extra_test.go | 2 +- pulls_test.go | 2 +- releases.go | 2 +- releases_extra_test.go | 2 +- releases_test.go | 2 +- repos.go | 2 +- repos_test.go | 2 +- service_pagination_extra_test.go | 2 +- teams.go | 2 +- teams_test.go | 2 +- users.go | 2 +- users_extra_test.go | 2 +- users_test.go | 2 +- webhooks.go | 2 +- webhooks_extra_test.go | 2 +- webhooks_test.go | 2 +- wiki.go | 2 +- wiki_test.go | 2 +- 57 files changed, 58 insertions(+), 58 deletions(-) diff --git a/actions.go b/actions.go index bb26a99..767b57d 100644 --- a/actions.go +++ b/actions.go @@ -7,7 +7,7 @@ import ( "strconv" core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // ActionsService handles CI/CD actions operations across repositories and diff --git a/actions_test.go b/actions_test.go index 755ce7b..38d59c8 100644 --- a/actions_test.go +++ b/actions_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestActionsService_ListRepoSecrets_Good(t *testing.T) { diff --git a/activitypub.go b/activitypub.go index e3141cb..4138fed 100644 --- a/activitypub.go +++ b/activitypub.go @@ -3,7 +3,7 @@ package forge import ( "context" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // ActivityPubService handles ActivityPub actor and inbox endpoints. diff --git a/activitypub_test.go b/activitypub_test.go index 6ad2442..07d65a6 100644 --- a/activitypub_test.go +++ b/activitypub_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestActivityPubService_GetInstanceActor_Good(t *testing.T) { diff --git a/admin.go b/admin.go index 331c18f..6768680 100644 --- a/admin.go +++ b/admin.go @@ -8,7 +8,7 @@ import ( "strconv" core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // AdminService handles site administration operations. diff --git a/admin_test.go b/admin_test.go index 8b31cb2..12c009e 100644 --- a/admin_test.go +++ b/admin_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestAdminService_ListUsers_Good(t *testing.T) { diff --git a/branches.go b/branches.go index af709a4..234d46a 100644 --- a/branches.go +++ b/branches.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // BranchService handles branch operations within a repository. diff --git a/branches_extra_test.go b/branches_extra_test.go index ce1bbfb..c6dca0b 100644 --- a/branches_extra_test.go +++ b/branches_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestBranchService_ListBranches_Good(t *testing.T) { diff --git a/branches_test.go b/branches_test.go index 650507d..2534f57 100644 --- a/branches_test.go +++ b/branches_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestBranchService_List_Good(t *testing.T) { diff --git a/cmd/forgegen/generator.go b/cmd/forgegen/generator.go index 83f89b2..82162c7 100644 --- a/cmd/forgegen/generator.go +++ b/cmd/forgegen/generator.go @@ -10,7 +10,7 @@ import ( "text/template" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) // typeGrouping maps type name prefixes to output file names. diff --git a/cmd/forgegen/generator_test.go b/cmd/forgegen/generator_test.go index 68ebf7f..af6a445 100644 --- a/cmd/forgegen/generator_test.go +++ b/cmd/forgegen/generator_test.go @@ -4,7 +4,7 @@ import ( "testing" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) func TestGenerate_CreatesFiles_Good(t *testing.T) { diff --git a/cmd/forgegen/main_test.go b/cmd/forgegen/main_test.go index 426a10e..cc109ef 100644 --- a/cmd/forgegen/main_test.go +++ b/cmd/forgegen/main_test.go @@ -4,7 +4,7 @@ import ( "testing" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) func TestMain_Run_Good(t *testing.T) { diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go index 1731e42..45756fd 100644 --- a/cmd/forgegen/parser.go +++ b/cmd/forgegen/parser.go @@ -6,7 +6,7 @@ import ( "slices" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) // Spec represents a Swagger 2.0 specification document. diff --git a/commits.go b/commits.go index 4bfbed3..1a1780e 100644 --- a/commits.go +++ b/commits.go @@ -5,7 +5,7 @@ import ( "iter" "strconv" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // CommitService handles commit-related operations such as commit statuses diff --git a/commits_extra_test.go b/commits_extra_test.go index 541876d..801e263 100644 --- a/commits_extra_test.go +++ b/commits_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestCommitService_GetCombinedStatusByRef_Good(t *testing.T) { diff --git a/commits_test.go b/commits_test.go index 0de4c6a..49e1481 100644 --- a/commits_test.go +++ b/commits_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestCommitService_List_Good(t *testing.T) { diff --git a/config.go b/config.go index 469acf2..58db749 100644 --- a/config.go +++ b/config.go @@ -7,7 +7,7 @@ import ( "strings" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) const ( diff --git a/config_test.go b/config_test.go index 5d35f47..2517085 100644 --- a/config_test.go +++ b/config_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) func TestResolveConfig_EnvOverrides_Good(t *testing.T) { diff --git a/contents.go b/contents.go index d0c8aa9..85e595c 100644 --- a/contents.go +++ b/contents.go @@ -6,7 +6,7 @@ import ( "net/url" core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // ContentService handles file read/write operations via the Forgejo API. diff --git a/contents_test.go b/contents_test.go index 3275642..6bee13c 100644 --- a/contents_test.go +++ b/contents_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestContentService_ListContents_Good(t *testing.T) { diff --git a/forge_test.go b/forge_test.go index 78e3792..57e6071 100644 --- a/forge_test.go +++ b/forge_test.go @@ -9,7 +9,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestForge_NewForge_Good(t *testing.T) { diff --git a/go.mod b/go.mod index e1ee4da..d34b3a8 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ -module dappco.re/go/core/forge +module dappco.re/go/forge go 1.26.0 require ( dappco.re/go/core v0.8.0-alpha.1 - dappco.re/go/core/io v0.4.1 + dappco.re/go/io v0.4.1 github.com/goccy/go-json v0.10.6 ) diff --git a/issues.go b/issues.go index 26839f8..eec6b14 100644 --- a/issues.go +++ b/issues.go @@ -8,7 +8,7 @@ import ( goio "io" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // IssueService handles issue operations within a repository. diff --git a/issues_extra_test.go b/issues_extra_test.go index f0e9a99..fcaf38c 100644 --- a/issues_extra_test.go +++ b/issues_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestIssueService_ListIssues_Good(t *testing.T) { diff --git a/issues_test.go b/issues_test.go index 38f8f21..2906562 100644 --- a/issues_test.go +++ b/issues_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func readMultipartAttachment(t *testing.T, r *http.Request) (string, string) { diff --git a/labels.go b/labels.go index 23633a6..f55ef58 100644 --- a/labels.go +++ b/labels.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // LabelService handles repository labels, organisation labels, and issue labels. diff --git a/labels_test.go b/labels_test.go index 92f8b87..be709aa 100644 --- a/labels_test.go +++ b/labels_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestLabelService_ListRepoLabels_Good(t *testing.T) { diff --git a/milestones.go b/milestones.go index 1032396..a32a278 100644 --- a/milestones.go +++ b/milestones.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // MilestoneListOptions controls filtering for repository milestone listings. diff --git a/milestones_test.go b/milestones_test.go index 8def945..855405e 100644 --- a/milestones_test.go +++ b/milestones_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestMilestoneService_List_Good(t *testing.T) { diff --git a/misc.go b/misc.go index d53d15f..3b8ff0e 100644 --- a/misc.go +++ b/misc.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // MiscService handles miscellaneous Forgejo API endpoints such as diff --git a/misc_test.go b/misc_test.go index 9685084..9039088 100644 --- a/misc_test.go +++ b/misc_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestMiscService_RenderMarkdown_Good(t *testing.T) { diff --git a/notifications.go b/notifications.go index cad311d..af5f956 100644 --- a/notifications.go +++ b/notifications.go @@ -9,7 +9,7 @@ import ( "time" core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // NotificationListOptions controls filtering for notification listings. diff --git a/notifications_test.go b/notifications_test.go index 45635bd..951bbb6 100644 --- a/notifications_test.go +++ b/notifications_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestNotificationService_List_Good(t *testing.T) { diff --git a/orgs.go b/orgs.go index c617fcf..bd896cb 100644 --- a/orgs.go +++ b/orgs.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // OrgService handles organisation operations. diff --git a/orgs_extra_test.go b/orgs_extra_test.go index 348333b..ac53b87 100644 --- a/orgs_extra_test.go +++ b/orgs_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestOrgService_ListOrgs_Good(t *testing.T) { diff --git a/orgs_test.go b/orgs_test.go index 81ac6d6..0b3eac7 100644 --- a/orgs_test.go +++ b/orgs_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestOrgService_List_Good(t *testing.T) { diff --git a/packages.go b/packages.go index 43784a1..239d621 100644 --- a/packages.go +++ b/packages.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // PackageService handles package registry operations via the Forgejo API. diff --git a/packages_test.go b/packages_test.go index a457e82..dbc6332 100644 --- a/packages_test.go +++ b/packages_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestPackageService_List_Good(t *testing.T) { diff --git a/pulls.go b/pulls.go index 6327abd..11d9f25 100644 --- a/pulls.go +++ b/pulls.go @@ -7,7 +7,7 @@ import ( "strconv" core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // PullService handles pull request operations within a repository. diff --git a/pulls_extra_test.go b/pulls_extra_test.go index 128787e..40f3fe9 100644 --- a/pulls_extra_test.go +++ b/pulls_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestPullService_ListPullRequests_Good(t *testing.T) { diff --git a/pulls_test.go b/pulls_test.go index 001ad4c..db84147 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -8,7 +8,7 @@ import ( "reflect" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestPullService_List_Good(t *testing.T) { diff --git a/releases.go b/releases.go index 2eac1ce..f3152bc 100644 --- a/releases.go +++ b/releases.go @@ -7,7 +7,7 @@ import ( goio "io" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // ReleaseService handles release operations within a repository. diff --git a/releases_extra_test.go b/releases_extra_test.go index 9b58159..4f6a1ca 100644 --- a/releases_extra_test.go +++ b/releases_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestReleaseService_ListReleases_Good(t *testing.T) { diff --git a/releases_test.go b/releases_test.go index 8d5ca7d..abed98e 100644 --- a/releases_test.go +++ b/releases_test.go @@ -12,7 +12,7 @@ import ( "reflect" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func readMultipartReleaseAttachment(t *testing.T, r *http.Request) (map[string]string, string, string) { diff --git a/repos.go b/repos.go index a96261a..433b1eb 100644 --- a/repos.go +++ b/repos.go @@ -9,7 +9,7 @@ import ( "time" core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // RepoService handles repository operations. diff --git a/repos_test.go b/repos_test.go index 3c6c22b..44f7d9b 100644 --- a/repos_test.go +++ b/repos_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestRepoService_ListActivityFeeds_Good(t *testing.T) { diff --git a/service_pagination_extra_test.go b/service_pagination_extra_test.go index c367c60..06eaf1b 100644 --- a/service_pagination_extra_test.go +++ b/service_pagination_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestRepoService_ListOrgReposPage_Good(t *testing.T) { diff --git a/teams.go b/teams.go index 88e414c..0b0eb94 100644 --- a/teams.go +++ b/teams.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // TeamService handles team operations. diff --git a/teams_test.go b/teams_test.go index 0572411..567f288 100644 --- a/teams_test.go +++ b/teams_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestTeamService_Get_Good(t *testing.T) { diff --git a/users.go b/users.go index fdc8b98..51c72c8 100644 --- a/users.go +++ b/users.go @@ -8,7 +8,7 @@ import ( "strconv" core "dappco.re/go/core" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // UserService handles user operations. diff --git a/users_extra_test.go b/users_extra_test.go index bcd80a3..18d08db 100644 --- a/users_extra_test.go +++ b/users_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestUserService_ListMyFollowing_Good(t *testing.T) { diff --git a/users_test.go b/users_test.go index bc4f2b3..8b9df26 100644 --- a/users_test.go +++ b/users_test.go @@ -8,7 +8,7 @@ import ( "strconv" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestUserService_Get_Good(t *testing.T) { diff --git a/webhooks.go b/webhooks.go index 23394d5..5478f69 100644 --- a/webhooks.go +++ b/webhooks.go @@ -4,7 +4,7 @@ import ( "context" "iter" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // WebhookService handles webhook (hook) operations within a repository. diff --git a/webhooks_extra_test.go b/webhooks_extra_test.go index b250582..7b0cdb2 100644 --- a/webhooks_extra_test.go +++ b/webhooks_extra_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestWebhookService_ListHooks_Good(t *testing.T) { diff --git a/webhooks_test.go b/webhooks_test.go index 68d1ff9..d9f3814 100644 --- a/webhooks_test.go +++ b/webhooks_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestWebhookService_List_Good(t *testing.T) { diff --git a/wiki.go b/wiki.go index 2243b3d..e67895f 100644 --- a/wiki.go +++ b/wiki.go @@ -5,7 +5,7 @@ import ( "iter" "strconv" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) // WikiService handles wiki page operations for a repository. diff --git a/wiki_test.go b/wiki_test.go index ca2ab13..cb3613e 100644 --- a/wiki_test.go +++ b/wiki_test.go @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - "dappco.re/go/core/forge/types" + "dappco.re/go/forge/types" ) func TestWikiService_ListPages_Good(t *testing.T) { From 56a88b56b8176705d54bde72a552d73a1555e974 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 24 Apr 2026 23:24:57 +0100 Subject: [PATCH 12/15] feat(ax-10): bring go-forge to v0.8.0-alpha.1 + CLI test scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump dappco.re/go/io to v0.8.0-alpha.1 (canonical target across dappco.re/go/* namespace per 2026-04-24 release-gate sweep) - Add tests/cli/forge/Taskfile.yaml + main.go AX-10 driver per RFC-CORE-008-AGENT-EXPERIENCE.md §10 - Driver skips cleanly when FORGE_URL/FORGE_TOKEN env vars unset; lists core repos via NewForge + Repos.ListOrgRepos when credentials present Closes tasks.lthn.sh/view.php?id=356 Co-Authored-By: Codex Co-Authored-By: Athena --- go.mod | 2 +- tests/cli/forge/Taskfile.yaml | 25 +++++++++++++++++++++++++ tests/cli/forge/main.go | 27 +++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/cli/forge/Taskfile.yaml create mode 100644 tests/cli/forge/main.go diff --git a/go.mod b/go.mod index d34b3a8..8abe29c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,6 @@ go 1.26.0 require ( dappco.re/go/core v0.8.0-alpha.1 - dappco.re/go/io v0.4.1 + dappco.re/go/io v0.8.0-alpha.1 github.com/goccy/go-json v0.10.6 ) diff --git a/tests/cli/forge/Taskfile.yaml b/tests/cli/forge/Taskfile.yaml new file mode 100644 index 0000000..43c9635 --- /dev/null +++ b/tests/cli/forge/Taskfile.yaml @@ -0,0 +1,25 @@ +version: "3" + +tasks: + default: + deps: [test] + + test: + desc: Validate the go-forge AX-10 CLI artifact driver. + dir: ../../.. + cmds: + - | + export GOCACHE="${GOCACHE:-/tmp/go-forge-gocache}" + export GOMODCACHE="${GOMODCACHE:-/tmp/go-forge-gomodcache}" + export GOWORK=off + mkdir -p "$GOCACHE" "$GOMODCACHE" + bin="$(mktemp -t core-forge.XXXXXX)" + trap 'rm -f "$bin"' EXIT + go build -o "$bin" ./tests/cli/forge + "$bin" + + driver: + desc: Run the go-forge AX-10 driver directly. + dir: ../../.. + cmds: + - GOWORK=off go run ./tests/cli/forge diff --git a/tests/cli/forge/main.go b/tests/cli/forge/main.go new file mode 100644 index 0000000..85f36c3 --- /dev/null +++ b/tests/cli/forge/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "fmt" + "os" + + forge "dappco.re/go/forge" +) + +func main() { + url := os.Getenv("FORGE_URL") + token := os.Getenv("FORGE_TOKEN") + if url == "" || token == "" { + fmt.Println("skip: FORGE_URL and FORGE_TOKEN are required") + return + } + + f := forge.NewForge(url, token) + repos, err := f.Repos.ListOrgRepos(context.Background(), "core") + if err != nil { + fmt.Fprintf(os.Stderr, "list core repos: %v\n", err) + os.Exit(1) + } + + fmt.Printf("core repos: %d\n", len(repos)) +} From 1ecbd03c300abcf62b651a2d76f660bc685362e4 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 06:49:01 +0100 Subject: [PATCH 13/15] fix(forge): AX-6 banned-import purge in client.go (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed bytes entirely - JSON/text request bodies → core.NewReader - Multipart bytes.Buffer → coreio.NewMemoryMedium - Response body reads → core.ReadAll - mime/multipart, net/url, strconv, residual goio retained with // Note: AX-6 intrinsic annotations Build + race tests PASS. Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=354 --- client.go | 86 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/client.go b/client.go index 3702f32..d9c3c1d 100644 --- a/client.go +++ b/client.go @@ -1,22 +1,22 @@ package forge import ( - // Note: bytes.Buffer/Reader for body buffering. - "bytes" "context" // Note: goccy/go-json — faster drop-in encoding/json replacement; no core equivalent for JSON-decoder performance profile. json "github.com/goccy/go-json" - // Note: multipart writer for file uploads; no core equivalent. + // Note: AX-6 intrinsic — multipart upload bodies require the standard multipart writer; core-io provides storage, not multipart encoding. "mime/multipart" // Note: HTTP client primitives; no core.Client equivalent. "net/http" + // Note: AX-6 intrinsic — URL parsing/query encoding is structural here; core.URLParse/core.URLEncode are not available in the pinned core module. "net/url" + // Note: AX-6 intrinsic — integer conversion, quoting, and rate-limit parsing need strconv; core.Itoa/core.Atoi are not available in the pinned core module. "strconv" - - // Note: io.Reader/Writer for streaming upload. + // Note: AX-6 intrinsic — stream types, multipart content piping, and bounded HTTP error reads require io; coreio Medium only replaces multipart buffering below. goio "io" core "dappco.re/go/core" + coreio "dappco.re/go/core/io" ) // APIError represents an error response from the Forgejo API. @@ -356,16 +356,17 @@ func (c *Client) PostRaw(ctx context.Context, path string, body any) ([]byte, er func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte, error) { url := c.baseURL + path - var bodyReader goio.Reader - if body != nil { + var req *http.Request + var err error + if body == nil { + req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + } else { data, err := json.Marshal(body) if err != nil { return nil, core.E("Client.PostRaw", "forge: marshal body", err) } - bodyReader = bytes.NewReader(data) + req, err = http.NewRequestWithContext(ctx, http.MethodPost, url, core.NewReader(string(data))) } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bodyReader) if err != nil { return nil, core.E("Client.PostRaw", "forge: create request", err) } @@ -390,7 +391,7 @@ func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte return nil, c.parseError(resp, path) } - data, err := goio.ReadAll(resp.Body) + data, err := readBody(resp.Body) if err != nil { return nil, core.E("Client.PostRaw", "forge: read response body", err) } @@ -401,7 +402,7 @@ func (c *Client) postRawJSON(ctx context.Context, path string, body any) ([]byte func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, error) { url := c.baseURL + path - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, core.NewReader(body)) if err != nil { return nil, core.E("Client.PostText", "forge: create request", err) } @@ -427,7 +428,7 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er return nil, c.parseError(resp, path) } - data, err := goio.ReadAll(resp.Body) + data, err := readBody(resp.Body) if err != nil { return nil, core.E("Client.PostText", "forge: read response body", err) } @@ -448,8 +449,19 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s target.RawQuery = values.Encode() } - var body bytes.Buffer - writer := multipart.NewWriter(&body) + body := coreio.NewMemoryMedium() + bodyWriter, err := body.Create("multipart") + if err != nil { + return core.E("Client.PostMultipart", "forge: create multipart body", err) + } + bodyWriterOpen := true + defer func() { + if bodyWriterOpen { + _ = bodyWriter.Close() + } + }() + + writer := multipart.NewWriter(bodyWriter) for key, value := range fields { if err := writer.WriteField(key, value); err != nil { return core.E("Client.PostMultipart", "forge: create multipart form field", err) @@ -469,8 +481,19 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s if err := writer.Close(); err != nil { return core.E("Client.PostMultipart", "forge: close multipart writer", err) } + if err := bodyWriter.Close(); err != nil { + bodyWriterOpen = false + return core.E("Client.PostMultipart", "forge: close multipart body", err) + } + bodyWriterOpen = false - req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), &body) + bodyReader, err := body.ReadStream("multipart") + if err != nil { + return core.E("Client.PostMultipart", "forge: read multipart body", err) + } + defer bodyReader.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bodyReader) if err != nil { return core.E("Client.PostMultipart", "forge: create request", err) } @@ -538,7 +561,7 @@ func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { return nil, c.parseError(resp, path) } - data, err := goio.ReadAll(resp.Body) + data, err := readBody(resp.Body) if err != nil { return nil, core.E("Client.GetRaw", "forge: read response body", err) } @@ -554,16 +577,17 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err func (c *Client) doJSON(ctx context.Context, method, path string, body, out any) (*http.Response, error) { url := c.baseURL + path - var bodyReader goio.Reader - if body != nil { + var req *http.Request + var err error + if body == nil { + req, err = http.NewRequestWithContext(ctx, method, url, nil) + } else { data, err := json.Marshal(body) if err != nil { return nil, core.E("Client.doJSON", "forge: marshal body", err) } - bodyReader = bytes.NewReader(data) + req, err = http.NewRequestWithContext(ctx, method, url, core.NewReader(string(data))) } - - req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { return nil, core.E("Client.doJSON", "forge: create request", err) } @@ -606,7 +630,7 @@ func (c *Client) parseError(resp *http.Response, path string) error { } // Read a bit of the body to see if we can get a message - data, _ := goio.ReadAll(goio.LimitReader(resp.Body, 1024)) + data, _ := readBody(goio.LimitReader(resp.Body, 1024)) _ = json.Unmarshal(data, &errBody) msg := errBody.Message @@ -624,6 +648,22 @@ func (c *Client) parseError(resp *http.Response, path string) error { } } +func readBody(reader any) ([]byte, error) { + result := core.ReadAll(reader) + if !result.OK { + if err, ok := result.Value.(error); ok { + return nil, err + } + return nil, core.E("Client.readBody", "forge: read body", nil) + } + + body, ok := result.Value.(string) + if !ok { + return nil, core.E("Client.readBody", "forge: read body", nil) + } + return []byte(body), nil +} + func (c *Client) updateRateLimit(resp *http.Response) { if limit := resp.Header.Get("X-RateLimit-Limit"); limit != "" { c.rateLimit.Limit, _ = strconv.Atoi(limit) From 5e56b0290aa398de8ceb71a1c8af56b3a932a05d Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 07:11:13 +0100 Subject: [PATCH 14/15] fix(forge): AX-6 banned-import purge across service files (#355) - Removed strconv / net/url from affected service files - Centralised int/bool parsing + query encoding in helpers.go (with AX-6 comments for missing core equivalents) - io.Reader retained only in upload APIs (issues.go, releases.go) as structural API body types Build + race tests PASS (with cache + modfile workaround). Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=355 --- actions.go | 24 +++++---------- commits.go | 13 ++++----- contents.go | 13 ++------- helpers.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ issues.go | 14 ++++----- notifications.go | 58 ++++++++++++++++-------------------- pulls.go | 36 +++++++++-------------- releases.go | 6 ++-- repos.go | 48 ++++++++++-------------------- users.go | 32 ++++++++------------ 10 files changed, 169 insertions(+), 151 deletions(-) diff --git a/actions.go b/actions.go index 767b57d..ef2ad89 100644 --- a/actions.go +++ b/actions.go @@ -3,10 +3,7 @@ package forge import ( "context" "iter" - "net/url" - "strconv" - core "dappco.re/go/core" "dappco.re/go/forge/types" ) @@ -213,19 +210,14 @@ func (s *ActionsService) ListRepoTasks(ctx context.Context, owner, repo string, path := ResolvePath("/api/v1/repos/{owner}/{repo}/actions/tasks", pathParams("owner", owner, "repo", repo)) if opts.Page > 0 || opts.Limit > 0 { - u, err := url.Parse(path) - if err != nil { - return nil, core.E("ActionsService.ListRepoTasks", "forge: parse path", err) - } - q := u.Query() - if opts.Page > 0 { - q.Set("page", strconv.Itoa(opts.Page)) - } - if opts.Limit > 0 { - q.Set("limit", strconv.Itoa(opts.Limit)) - } - u.RawQuery = q.Encode() - path = u.String() + path = appendQuery(path, func(q *queryBuilder) { + if opts.Page > 0 { + q.Set("page", intString(opts.Page)) + } + if opts.Limit > 0 { + q.Set("limit", intString(opts.Limit)) + } + }) } var out types.ActionTaskResponse diff --git a/commits.go b/commits.go index 1a1780e..a7f964e 100644 --- a/commits.go +++ b/commits.go @@ -3,7 +3,6 @@ package forge import ( "context" "iter" - "strconv" "dappco.re/go/forge/types" ) @@ -60,13 +59,13 @@ func (o CommitListOptions) queryParams() map[string]string { query["path"] = o.Path } if o.Stat != nil { - query["stat"] = strconv.FormatBool(*o.Stat) + query["stat"] = boolString(*o.Stat) } if o.Verification != nil { - query["verification"] = strconv.FormatBool(*o.Verification) + query["verification"] = boolString(*o.Verification) } if o.Files != nil { - query["files"] = strconv.FormatBool(*o.Files) + query["files"] = boolString(*o.Files) } if o.Not != "" { query["not"] = o.Not @@ -316,13 +315,13 @@ func commitListQueryFromCompat(filter types.ListCommitsOption) map[string]string query["path"] = filter.Path } if filter.Stat != nil { - query["stat"] = strconv.FormatBool(*filter.Stat) + query["stat"] = boolString(*filter.Stat) } if filter.Verification != nil { - query["verification"] = strconv.FormatBool(*filter.Verification) + query["verification"] = boolString(*filter.Verification) } if filter.Files != nil { - query["files"] = strconv.FormatBool(*filter.Files) + query["files"] = boolString(*filter.Files) } if filter.Not != "" { query["not"] = filter.Not diff --git a/contents.go b/contents.go index 85e595c..dd01da2 100644 --- a/contents.go +++ b/contents.go @@ -3,9 +3,7 @@ package forge import ( "context" "iter" - "net/url" - core "dappco.re/go/core" "dappco.re/go/forge/types" ) @@ -29,14 +27,9 @@ func newContentService(c *Client) *ContentService { func (s *ContentService) ListContents(ctx context.Context, owner, repo, ref string) ([]types.ContentsResponse, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/contents", pathParams("owner", owner, "repo", repo)) if ref != "" { - u, err := url.Parse(path) - if err != nil { - return nil, core.E("ContentService.ListContents", "forge: parse path", err) - } - q := u.Query() - q.Set("ref", ref) - u.RawQuery = q.Encode() - path = u.String() + path = appendQuery(path, func(q *queryBuilder) { + q.Set("ref", ref) + }) } var out []types.ContentsResponse diff --git a/helpers.go b/helpers.go index 4c15c90..167751a 100644 --- a/helpers.go +++ b/helpers.go @@ -2,6 +2,10 @@ package forge import ( "fmt" + // Note: AX-6 intrinsic — query escaping is centralised here because the pinned core module has no core.URLEncode helper. + "net/url" + "sort" + // Note: AX-6 intrinsic — the pinned core module has no core.Itoa/core.FormatInt/core.Atoi/core.FormatBool/core.Quote helpers. "strconv" "strings" "time" @@ -20,6 +24,19 @@ func int64String(v int64) string { return strconv.FormatInt(v, 10) } +func intString(v int) string { + return strconv.Itoa(v) +} + +func boolString(v bool) string { + return strconv.FormatBool(v) +} + +func parseInt(value string) int { + v, _ := strconv.Atoi(value) + return v +} + func pathParams(values ...string) Params { params := make(Params, len(values)/2) for i := 0; i+1 < len(values); i += 2 { @@ -119,3 +136,62 @@ func lastIndexByte(s string, b byte) int { } return -1 } + +type queryBuilder struct { + values map[string][]string +} + +func newQueryBuilder() *queryBuilder { + return &queryBuilder{values: make(map[string][]string)} +} + +func (q *queryBuilder) Set(key, value string) { + q.values[key] = []string{value} +} + +func (q *queryBuilder) Add(key, value string) { + q.values[key] = append(q.values[key], value) +} + +func (q *queryBuilder) Encode() string { + if q == nil || len(q.values) == 0 { + return "" + } + + keys := make([]string, 0, len(q.values)) + for key := range q.values { + keys = append(keys, key) + } + sort.Strings(keys) + + var b strings.Builder + for _, key := range keys { + escapedKey := url.QueryEscape(key) + for _, value := range q.values[key] { + if b.Len() > 0 { + b.WriteByte('&') + } + b.WriteString(escapedKey) + b.WriteByte('=') + b.WriteString(url.QueryEscape(value)) + } + } + return b.String() +} + +func encodeQuery(build func(*queryBuilder)) string { + query := newQueryBuilder() + build(query) + return query.Encode() +} + +func appendQuery(path string, build func(*queryBuilder)) string { + query := encodeQuery(build) + if query == "" { + return path + } + if strings.Contains(path, "?") { + return path + "&" + query + } + return path + "?" + query +} diff --git a/issues.go b/issues.go index eec6b14..4437fde 100644 --- a/issues.go +++ b/issues.go @@ -3,9 +3,9 @@ package forge import ( "context" "iter" - "strconv" "time" + // Note: AX-6 intrinsic — upload APIs must expose the structural request body type; coreio Medium is used inside Client multipart handling. goio "io" "dappco.re/go/forge/types" @@ -242,7 +242,7 @@ func (o SearchIssuesOptions) queryParams() map[string]string { query["q"] = o.Query } if o.PriorityRepoID != 0 { - query["priority_repo_id"] = strconv.FormatInt(o.PriorityRepoID, 10) + query["priority_repo_id"] = int64String(o.PriorityRepoID) } if o.Type != "" { query["type"] = o.Type @@ -254,19 +254,19 @@ func (o SearchIssuesOptions) queryParams() map[string]string { query["before"] = o.Before.Format(time.RFC3339) } if o.Assigned { - query["assigned"] = strconv.FormatBool(true) + query["assigned"] = "true" } if o.Created { - query["created"] = strconv.FormatBool(true) + query["created"] = "true" } if o.Mentioned { - query["mentioned"] = strconv.FormatBool(true) + query["mentioned"] = "true" } if o.ReviewRequested { - query["review_requested"] = strconv.FormatBool(true) + query["review_requested"] = "true" } if o.Reviewed { - query["reviewed"] = strconv.FormatBool(true) + query["reviewed"] = "true" } if o.Owner != "" { query["owner"] = o.Owner diff --git a/notifications.go b/notifications.go index af5f956..a4500dd 100644 --- a/notifications.go +++ b/notifications.go @@ -4,11 +4,8 @@ import ( "context" "iter" "net/http" - "net/url" - "strconv" "time" - core "dappco.re/go/core" "dappco.re/go/forge/types" ) @@ -39,7 +36,7 @@ func (o NotificationListOptions) String() string { // GoString returns a safe Go-syntax summary of the notification filters. func (o NotificationListOptions) GoString() string { return o.String() } -func (o NotificationListOptions) addQuery(values url.Values) { +func (o NotificationListOptions) addQuery(values *queryBuilder) { if o.All { values.Set("all", "true") } @@ -127,22 +124,22 @@ func newNotificationService(c *Client) *NotificationService { } func notificationMarkQueryString(all bool, statusTypes []string, toStatus string, lastReadAt *time.Time) string { - values := url.Values{} - if all { - values.Set("all", "true") - } - for _, status := range statusTypes { - if status != "" { - values.Add("status-types", status) + return encodeQuery(func(values *queryBuilder) { + if all { + values.Set("all", "true") } - } - if toStatus != "" { - values.Set("to-status", toStatus) - } - if lastReadAt != nil { - values.Set("last_read_at", lastReadAt.Format(time.RFC3339)) - } - return values.Encode() + for _, status := range statusTypes { + if status != "" { + values.Add("status-types", status) + } + } + if toStatus != "" { + values.Set("to-status", toStatus) + } + if lastReadAt != nil { + values.Set("last_read_at", lastReadAt.Format(time.RFC3339)) + } + }) } func (o NotificationRepoMarkOptions) queryString() string { @@ -284,26 +281,21 @@ func (s *NotificationService) listPage(ctx context.Context, path string, opts Li opts.Limit = defaultPageLimit } - u, err := url.Parse(path) - if err != nil { - return nil, core.E("NotificationService.listPage", "forge: parse path", err) - } - - values := u.Query() - values.Set("page", strconv.Itoa(opts.Page)) - values.Set("limit", strconv.Itoa(opts.Limit)) - for _, filter := range filters { - filter.addQuery(values) - } - u.RawQuery = values.Encode() + path = appendQuery(path, func(values *queryBuilder) { + values.Set("page", intString(opts.Page)) + values.Set("limit", intString(opts.Limit)) + for _, filter := range filters { + filter.addQuery(values) + } + }) var items []types.NotificationThread - resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &items) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, &items) if err != nil { return nil, err } - totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + totalCount := parseInt(resp.Header.Get("X-Total-Count")) return &PagedResult[types.NotificationThread]{ Items: items, TotalCount: totalCount, diff --git a/pulls.go b/pulls.go index 11d9f25..35a2acb 100644 --- a/pulls.go +++ b/pulls.go @@ -3,10 +3,7 @@ package forge import ( "context" "iter" - "net/url" - "strconv" - core "dappco.re/go/core" "dappco.re/go/forge/types" ) @@ -47,7 +44,7 @@ func (o PullListOptions) String() string { // GoString returns a safe Go-syntax summary of the pull request list filters. func (o PullListOptions) GoString() string { return o.String() } -func (o PullListOptions) addQuery(values url.Values) { +func (o PullListOptions) addQuery(values *queryBuilder) { if o.State != "" { values.Set("state", o.State) } @@ -55,11 +52,11 @@ func (o PullListOptions) addQuery(values url.Values) { values.Set("sort", o.Sort) } if o.Milestone != 0 { - values.Set("milestone", strconv.FormatInt(o.Milestone, 10)) + values.Set("milestone", int64String(o.Milestone)) } for _, label := range o.Labels { if label != 0 { - values.Add("labels", strconv.FormatInt(label, 10)) + values.Add("labels", int64String(label)) } } if o.Poster != "" { @@ -309,24 +306,19 @@ func (s *PullService) listPage(ctx context.Context, owner, repo string, opts Lis } path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) - u, err := url.Parse(path) - if err != nil { - return nil, core.E("PullService.listPage", "forge: parse path", err) - } - - values := u.Query() - values.Set("page", strconv.Itoa(opts.Page)) - values.Set("limit", strconv.Itoa(pageSize)) - addPullFilters(values, filters...) - u.RawQuery = values.Encode() + path = appendQuery(path, func(values *queryBuilder) { + values.Set("page", intString(opts.Page)) + values.Set("limit", intString(pageSize)) + addPullFilters(values, filters...) + }) var items []types.PullRequest - resp, err := s.client.doJSON(ctx, "GET", u.String(), nil, &items) + resp, err := s.client.doJSON(ctx, "GET", path, nil, &items) if err != nil { return nil, err } - totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + totalCount := parseInt(resp.Header.Get("X-Total-Count")) return &PagedResult[types.PullRequest]{ Items: items, TotalCount: totalCount, @@ -377,7 +369,7 @@ func (s *PullService) listIter(ctx context.Context, owner, repo string, filters } } -func addPullFilters(values url.Values, filters ...any) { +func addPullFilters(values *queryBuilder, filters ...any) { for _, filter := range filters { switch v := filter.(type) { case PullListOptions: @@ -396,7 +388,7 @@ func addPullFilters(values url.Values, filters ...any) { } } -func addCompatPullFilter(values url.Values, filter types.ListPullRequestsOption) { +func addCompatPullFilter(values *queryBuilder, filter types.ListPullRequestsOption) { if filter.State != "" { values.Set("state", filter.State) } @@ -404,11 +396,11 @@ func addCompatPullFilter(values url.Values, filter types.ListPullRequestsOption) values.Set("sort", filter.Sort) } if filter.Milestone != 0 { - values.Set("milestone", strconv.FormatInt(filter.Milestone, 10)) + values.Set("milestone", int64String(filter.Milestone)) } for _, label := range filter.Labels { if label != 0 { - values.Add("labels", strconv.FormatInt(label, 10)) + values.Add("labels", int64String(label)) } } if filter.Poster != "" { diff --git a/releases.go b/releases.go index f3152bc..f1a744b 100644 --- a/releases.go +++ b/releases.go @@ -3,8 +3,8 @@ package forge import ( "context" "iter" - "strconv" + // Note: AX-6 intrinsic — upload APIs must expose the structural request body type; coreio Medium is used inside Client multipart handling. goio "io" "dappco.re/go/forge/types" @@ -46,10 +46,10 @@ func (o ReleaseListOptions) GoString() string { return o.String() } func (o ReleaseListOptions) queryParams() map[string]string { query := make(map[string]string, 3) if o.Draft { - query["draft"] = strconv.FormatBool(true) + query["draft"] = "true" } if o.PreRelease { - query["pre-release"] = strconv.FormatBool(true) + query["pre-release"] = "true" } if o.Query != "" { query["q"] = o.Query diff --git a/repos.go b/repos.go index 433b1eb..3d072b5 100644 --- a/repos.go +++ b/repos.go @@ -4,11 +4,8 @@ import ( "context" "iter" "net/http" - "net/url" - "strconv" "time" - core "dappco.re/go/core" "dappco.re/go/forge/types" ) @@ -43,7 +40,7 @@ func (o RepoKeyListOptions) GoString() string { return o.String() } func (o RepoKeyListOptions) queryParams() map[string]string { query := make(map[string]string, 2) if o.KeyID != 0 { - query["key_id"] = strconv.FormatInt(o.KeyID, 10) + query["key_id"] = int64String(o.KeyID) } if o.Fingerprint != "" { query["fingerprint"] = o.Fingerprint @@ -507,14 +504,9 @@ func (s *RepoService) GetRawFile(ctx context.Context, owner, repo, filepath stri func (s *RepoService) GetRawFileOrLFS(ctx context.Context, owner, repo, filepath, ref string) ([]byte, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/media/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) if ref != "" { - u, err := url.Parse(path) - if err != nil { - return nil, core.E("RepoService.GetRawFileOrLFS", "forge: parse path", err) - } - q := u.Query() - q.Set("ref", ref) - u.RawQuery = q.Encode() - path = u.String() + path = appendQuery(path, func(q *queryBuilder) { + q.Set("ref", ref) + }) } return s.client.GetRaw(ctx, path) } @@ -523,14 +515,9 @@ func (s *RepoService) GetRawFileOrLFS(ctx context.Context, owner, repo, filepath func (s *RepoService) GetEditorConfig(ctx context.Context, owner, repo, filepath, ref string) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/editorconfig/{filepath}", pathParams("owner", owner, "repo", repo, "filepath", filepath)) if ref != "" { - u, err := url.Parse(path) - if err != nil { - return core.E("RepoService.GetEditorConfig", "forge: parse path", err) - } - q := u.Query() - q.Set("ref", ref) - u.RawQuery = q.Encode() - path = u.String() + path = appendQuery(path, func(q *queryBuilder) { + q.Set("ref", ref) + }) } return s.client.Get(ctx, path, nil) } @@ -692,24 +679,19 @@ func (s *RepoService) SearchRepositoriesPage(ctx context.Context, query string, pageOpts.Limit = 50 } - u, err := url.Parse("/api/v1/repos/search") - if err != nil { - return nil, core.E("RepoService.SearchRepositoriesPage", "forge: parse path", err) - } - - q := u.Query() - q.Set("q", query) - q.Set("page", strconv.Itoa(pageOpts.Page)) - q.Set("limit", strconv.Itoa(pageOpts.Limit)) - u.RawQuery = q.Encode() + path := appendQuery("/api/v1/repos/search", func(q *queryBuilder) { + q.Set("q", query) + q.Set("page", intString(pageOpts.Page)) + q.Set("limit", intString(pageOpts.Limit)) + }) var out types.SearchResults - resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &out) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, &out) if err != nil { return nil, err } - totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + totalCount := parseInt(resp.Header.Get("X-Total-Count")) items := make([]types.Repository, 0, len(out.Data)) for _, repo := range out.Data { if repo != nil { @@ -1087,7 +1069,7 @@ func repoKeyQuery(filters ...RepoKeyListOptions) map[string]string { query := make(map[string]string, 2) for _, filter := range filters { if filter.KeyID != 0 { - query["key_id"] = strconv.FormatInt(filter.KeyID, 10) + query["key_id"] = int64String(filter.KeyID) } if filter.Fingerprint != "" { query["fingerprint"] = filter.Fingerprint diff --git a/users.go b/users.go index 51c72c8..c1b417f 100644 --- a/users.go +++ b/users.go @@ -4,10 +4,7 @@ import ( "context" "iter" "net/http" - "net/url" - "strconv" - core "dappco.re/go/core" "dappco.re/go/forge/types" ) @@ -43,7 +40,7 @@ func (o UserSearchOptions) queryParams() map[string]string { return nil } return map[string]string{ - "uid": strconv.FormatInt(o.UID, 10), + "uid": int64String(o.UID), } } @@ -167,29 +164,24 @@ func (s *UserService) SearchUsersPage(ctx context.Context, query string, pageOpt pageSize = 50 } - u, err := url.Parse("/api/v1/users/search") - if err != nil { - return nil, core.E("UserService.SearchUsersPage", "forge: parse path", err) - } - - q := u.Query() - q.Set("q", query) - for _, filter := range filters { - for key, value := range filter.queryParams() { - q.Set(key, value) + path := appendQuery("/api/v1/users/search", func(q *queryBuilder) { + q.Set("q", query) + for _, filter := range filters { + for key, value := range filter.queryParams() { + q.Set(key, value) + } } - } - q.Set("page", strconv.Itoa(pageOpts.Page)) - q.Set("limit", strconv.Itoa(pageSize)) - u.RawQuery = q.Encode() + q.Set("page", intString(pageOpts.Page)) + q.Set("limit", intString(pageSize)) + }) var out userSearchResults - resp, err := s.client.doJSON(ctx, http.MethodGet, u.String(), nil, &out) + resp, err := s.client.doJSON(ctx, http.MethodGet, path, nil, &out) if err != nil { return nil, err } - totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + totalCount := parseInt(resp.Header.Get("X-Total-Count")) items := make([]types.User, 0, len(out.Data)) for _, user := range out.Data { if user != nil { From 2964139a9b0b499926d2d88290ae3358fe8e3568 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 09:41:54 +0100 Subject: [PATCH 15/15] fix(forge): AX-6 banned-import purge in client.go (#739) Removed net/url + strconv. Replaced with core.URLParse, core.Itoa, core.FormatInt, core.Atoi, core.ParseInt. Annotated retained context, mime/multipart, net/http, aliased io as AX-6 structural. Also fixed stale coreio import path to dappco.re/go/io. Closes tasks.lthn.sh/view.php?id=739 Co-authored-by: Codex --- client.go | 77 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/client.go b/client.go index d9c3c1d..15b6b4b 100644 --- a/client.go +++ b/client.go @@ -1,22 +1,19 @@ package forge import ( + // Note: AX-6 — request cancellation and deadlines are structural to HTTP calls; no core context primitive exists. "context" // Note: goccy/go-json — faster drop-in encoding/json replacement; no core equivalent for JSON-decoder performance profile. json "github.com/goccy/go-json" - // Note: AX-6 intrinsic — multipart upload bodies require the standard multipart writer; core-io provides storage, not multipart encoding. + // Note: AX-6 — multipart upload bodies require the standard multipart writer; core-io provides storage, not multipart encoding. "mime/multipart" - // Note: HTTP client primitives; no core.Client equivalent. + // Note: AX-6 — this low-level Forgejo client owns the HTTP boundary; no core.Client equivalent exists. "net/http" - // Note: AX-6 intrinsic — URL parsing/query encoding is structural here; core.URLParse/core.URLEncode are not available in the pinned core module. - "net/url" - // Note: AX-6 intrinsic — integer conversion, quoting, and rate-limit parsing need strconv; core.Itoa/core.Atoi are not available in the pinned core module. - "strconv" - // Note: AX-6 intrinsic — stream types, multipart content piping, and bounded HTTP error reads require io; coreio Medium only replaces multipart buffering below. + // Note: AX-6 — stream types, multipart content piping, and bounded HTTP error reads require io; coreio Medium only replaces multipart buffering below. goio "io" core "dappco.re/go/core" - coreio "dappco.re/go/core/io" + coreio "dappco.re/go/io" ) // APIError represents an error response from the Forgejo API. @@ -41,7 +38,7 @@ func (e *APIError) Error() string { if e == nil { return "forge.APIError{}" } - return core.Concat("forge: ", e.URL, " ", strconv.Itoa(e.StatusCode), ": ", e.Message) + return core.Concat("forge: ", e.URL, " ", core.Itoa(e.StatusCode), ": ", e.Message) } // String returns a safe summary of the API error. @@ -140,11 +137,11 @@ type RateLimit struct { func (r RateLimit) String() string { return core.Concat( "forge.RateLimit{limit=", - strconv.Itoa(r.Limit), + core.Itoa(r.Limit), ", remaining=", - strconv.Itoa(r.Remaining), + core.Itoa(r.Remaining), ", reset=", - strconv.FormatInt(r.Reset, 10), + core.FormatInt(r.Reset, 10), "}", ) } @@ -231,7 +228,7 @@ func (c *Client) String() string { if c.HasToken() { tokenState = "set" } - return core.Concat("forge.Client{baseURL=", strconv.Quote(c.baseURL), ", token=", tokenState, ", userAgent=", strconv.Quote(c.userAgent), "}") + return core.Concat("forge.Client{baseURL=", core.Sprintf("%q", c.baseURL), ", token=", tokenState, ", userAgent=", core.Sprintf("%q", c.userAgent), "}") } // GoString returns a safe Go-syntax summary of the client configuration. @@ -437,16 +434,32 @@ func (c *Client) postRawText(ctx context.Context, path, body string) ([]byte, er } func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[string]string, fields map[string]string, fieldName, fileName string, content goio.Reader, out any) error { - target, err := url.Parse(c.baseURL + path) - if err != nil { + target := c.baseURL + path + parsedTarget := core.URLParse(target) + if !parsedTarget.OK { + var err error + if parseErr, ok := parsedTarget.Value.(error); ok { + err = parseErr + } return core.E("Client.PostMultipart", "forge: parse url", err) } + targetStringer, ok := parsedTarget.Value.(interface{ String() string }) + if !ok { + return core.E("Client.PostMultipart", "forge: parse url", nil) + } + target = targetStringer.String() if len(query) > 0 { - values := target.Query() + values := newQueryBuilder() for key, value := range query { values.Set(key, value) } - target.RawQuery = values.Encode() + if encoded := values.Encode(); encoded != "" { + separator := "?" + if core.Contains(target, "?") { + separator = "&" + } + target = core.Concat(target, separator, encoded) + } } body := coreio.NewMemoryMedium() @@ -493,7 +506,7 @@ func (c *Client) postMultipartJSON(ctx context.Context, path string, query map[s } defer bodyReader.Close() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bodyReader) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bodyReader) if err != nil { return core.E("Client.PostMultipart", "forge: create request", err) } @@ -666,14 +679,36 @@ func readBody(reader any) ([]byte, error) { func (c *Client) updateRateLimit(resp *http.Response) { if limit := resp.Header.Get("X-RateLimit-Limit"); limit != "" { - c.rateLimit.Limit, _ = strconv.Atoi(limit) + c.rateLimit.Limit = parseRateLimitInt(limit) } if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining != "" { - c.rateLimit.Remaining, _ = strconv.Atoi(remaining) + c.rateLimit.Remaining = parseRateLimitInt(remaining) } if reset := resp.Header.Get("X-RateLimit-Reset"); reset != "" { - c.rateLimit.Reset, _ = strconv.ParseInt(reset, 10, 64) + c.rateLimit.Reset = parseRateLimitInt64(reset) + } +} + +func parseRateLimitInt(value string) int { + result := core.Atoi(value) + if !result.OK { + return 0 + } + if parsed, ok := result.Value.(int); ok { + return parsed + } + return 0 +} + +func parseRateLimitInt64(value string) int64 { + result := core.ParseInt(value, 10, 64) + if !result.OK { + return 0 + } + if parsed, ok := result.Value.(int64); ok { + return parsed } + return 0 } func (c *Client) authorizationHeader() string {