From 09d01bee96ee9543b990f3feabdbcc6d9398333a Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 08:33:11 +0000 Subject: [PATCH] feat(forge): add repository list filters Co-Authored-By: Virgil --- ax_stringer_test.go | 32 ++++++++++ commits.go | 82 ++++++++++++++++++++++++-- commits_test.go | 49 ++++++++++++++++ helpers.go | 7 +++ issues.go | 96 ++++++++++++++++++++++++++++-- issues_test.go | 58 +++++++++++++++++++ pulls.go | 138 ++++++++++++++++++++++++++++++++++++++++++-- pulls_test.go | 48 +++++++++++++++ releases.go | 62 ++++++++++++++++++-- releases_test.go | 41 +++++++++++++ 10 files changed, 593 insertions(+), 20 deletions(-) diff --git a/ax_stringer_test.go b/ax_stringer_test.go index 0f96a3e..e638ca5 100644 --- a/ax_stringer_test.go +++ b/ax_stringer_test.go @@ -109,6 +109,38 @@ func TestOption_Stringers_Good(t *testing.T) { got: SearchIssuesOptions{State: "open", PriorityRepoID: 99, Assigned: true, Query: "build"}, want: `forge.SearchIssuesOptions{state="open", q="build", priority_repo_id=99, assigned=true}`, }, + { + name: "IssueListOptions", + got: IssueListOptions{State: "open", Labels: "bug", Query: "panic", CreatedBy: "alice"}, + want: `forge.IssueListOptions{state="open", labels="bug", q="panic", created_by="alice"}`, + }, + { + name: "PullListOptions", + got: PullListOptions{State: "open", Sort: "priority", Milestone: 7, Labels: []int64{1, 2}, Poster: "alice"}, + want: `forge.PullListOptions{state="open", sort="priority", milestone=7, labels=[]int64{1, 2}, poster="alice"}`, + }, + { + name: "ReleaseListOptions", + got: ReleaseListOptions{Draft: true, PreRelease: true, Query: "1.0"}, + want: `forge.ReleaseListOptions{draft=true, pre-release=true, q="1.0"}`, + }, + { + name: "CommitListOptions", + got: func() CommitListOptions { + stat := false + verification := false + files := false + return CommitListOptions{ + Sha: "main", + Path: "docs", + Stat: &stat, + Verification: &verification, + Files: &files, + Not: "deadbeef", + } + }(), + want: `forge.CommitListOptions{sha="main", path="docs", stat=false, verification=false, files=false, not="deadbeef"}`, + }, { name: "ReleaseAttachmentUploadOptions", got: ReleaseAttachmentUploadOptions{Name: "release.zip"}, diff --git a/commits.go b/commits.go index 38631fc..8735b46 100644 --- a/commits.go +++ b/commits.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "strconv" "dappco.re/go/core/forge/types" ) @@ -20,6 +21,62 @@ type CommitService struct { client *Client } +// CommitListOptions controls filtering for repository commit listings. +// +// Usage: +// +// stat := false +// opts := forge.CommitListOptions{Sha: "main", Stat: &stat} +type CommitListOptions struct { + Sha string + Path string + Stat *bool + Verification *bool + Files *bool + Not string +} + +// String returns a safe summary of the commit list filters. +func (o CommitListOptions) String() string { + return optionString("forge.CommitListOptions", + "sha", o.Sha, + "path", o.Path, + "stat", o.Stat, + "verification", o.Verification, + "files", o.Files, + "not", o.Not, + ) +} + +// GoString returns a safe Go-syntax summary of the commit list filters. +func (o CommitListOptions) GoString() string { return o.String() } + +func (o CommitListOptions) queryParams() map[string]string { + query := make(map[string]string, 6) + if o.Sha != "" { + query["sha"] = o.Sha + } + if o.Path != "" { + query["path"] = o.Path + } + if o.Stat != nil { + query["stat"] = strconv.FormatBool(*o.Stat) + } + if o.Verification != nil { + query["verification"] = strconv.FormatBool(*o.Verification) + } + if o.Files != nil { + query["files"] = strconv.FormatBool(*o.Files) + } + if o.Not != "" { + query["not"] = o.Not + } + if len(query) == 0 { + return nil + } + return query +} + const ( commitCollectionPath = "/api/v1/repos/{owner}/{repo}/commits" commitItemPath = "/api/v1/repos/{owner}/{repo}/git/commits/{sha}" @@ -30,18 +87,18 @@ func newCommitService(c *Client) *CommitService { } // List returns a single page of commits for a repository. -func (s *CommitService) List(ctx context.Context, params Params, opts ListOptions) (*PagedResult[types.Commit], error) { - return ListPage[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), nil, opts) +func (s *CommitService) List(ctx context.Context, params Params, opts ListOptions, filters ...CommitListOptions) (*PagedResult[types.Commit], error) { + return ListPage[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), commitListQuery(filters...), opts) } // ListAll returns all commits for a repository. -func (s *CommitService) ListAll(ctx context.Context, params Params) ([]types.Commit, error) { - return ListAll[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), nil) +func (s *CommitService) ListAll(ctx context.Context, params Params, filters ...CommitListOptions) ([]types.Commit, error) { + return ListAll[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), commitListQuery(filters...)) } // Iter returns an iterator over all commits for a repository. -func (s *CommitService) Iter(ctx context.Context, params Params) iter.Seq2[types.Commit, error] { - return ListIter[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), nil) +func (s *CommitService) Iter(ctx context.Context, params Params, filters ...CommitListOptions) iter.Seq2[types.Commit, error] { + return ListIter[types.Commit](ctx, s.client, ResolvePath(commitCollectionPath, params), commitListQuery(filters...)) } // Get returns a single commit by SHA or ref. @@ -155,3 +212,16 @@ func (s *CommitService) DeleteNote(ctx context.Context, owner, repo, sha string) path := ResolvePath("/api/v1/repos/{owner}/{repo}/git/notes/{sha}", pathParams("owner", owner, "repo", repo, "sha", sha)) return s.client.Delete(ctx, path) } + +func commitListQuery(filters ...CommitListOptions) map[string]string { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} diff --git a/commits_test.go b/commits_test.go index ef9c09d..0de4c6a 100644 --- a/commits_test.go +++ b/commits_test.go @@ -62,6 +62,55 @@ func TestCommitService_List_Good(t *testing.T) { } } +func TestCommitService_ListFiltered_Good(t *testing.T) { + stat := false + verification := false + files := false + + 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) + } + want := map[string]string{ + "sha": "main", + "path": "docs", + "stat": "false", + "verification": "false", + "files": "false", + "not": "deadbeef", + "page": "1", + "limit": "50", + } + for key, wantValue := range want { + if got := r.URL.Query().Get(key); got != wantValue { + t.Errorf("got %s=%q, want %q", key, got, wantValue) + } + } + 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.ListAll(context.Background(), Params{"owner": "core", "repo": "go-forge"}, CommitListOptions{ + Sha: "main", + Path: "docs", + Stat: &stat, + Verification: &verification, + Files: &files, + Not: "deadbeef", + }) + if err != nil { + t.Fatal(err) + } + if len(commits) != 1 || commits[0].SHA != "abc123" { + t.Fatalf("got %#v", commits) + } +} + func TestCommitService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/helpers.go b/helpers.go index 64a0b12..4c15c90 100644 --- a/helpers.go +++ b/helpers.go @@ -69,6 +69,8 @@ func isZeroOptionValue(v any) bool { return len(x) == 0 case *time.Time: return x == nil + case *bool: + return x == nil case time.Time: return x.IsZero() default: @@ -93,6 +95,11 @@ func formatOptionValue(v any) string { return "" } return strconv.Quote(x.Format(time.RFC3339)) + case *bool: + if x == nil { + return "" + } + return strconv.FormatBool(*x) case time.Time: return strconv.Quote(x.Format(time.RFC3339)) default: diff --git a/issues.go b/issues.go index 177866a..b26c4a9 100644 --- a/issues.go +++ b/issues.go @@ -21,6 +21,81 @@ type IssueService struct { Resource[types.Issue, types.CreateIssueOption, types.EditIssueOption] } +// IssueListOptions controls filtering for repository issue listings. +// +// Usage: +// +// opts := forge.IssueListOptions{State: "open", Labels: "bug"} +type IssueListOptions struct { + State string + Labels string + Query string + Type string + Milestones string + Since *time.Time + Before *time.Time + CreatedBy string + AssignedBy string + MentionedBy string +} + +// String returns a safe summary of the issue list filters. +func (o IssueListOptions) String() string { + return optionString("forge.IssueListOptions", + "state", o.State, + "labels", o.Labels, + "q", o.Query, + "type", o.Type, + "milestones", o.Milestones, + "since", o.Since, + "before", o.Before, + "created_by", o.CreatedBy, + "assigned_by", o.AssignedBy, + "mentioned_by", o.MentionedBy, + ) +} + +// GoString returns a safe Go-syntax summary of the issue list filters. +func (o IssueListOptions) GoString() string { return o.String() } + +func (o IssueListOptions) queryParams() map[string]string { + query := make(map[string]string, 10) + if o.State != "" { + query["state"] = o.State + } + if o.Labels != "" { + query["labels"] = o.Labels + } + if o.Query != "" { + query["q"] = o.Query + } + if o.Type != "" { + query["type"] = o.Type + } + if o.Milestones != "" { + query["milestones"] = o.Milestones + } + if o.Since != nil { + query["since"] = o.Since.Format(time.RFC3339) + } + if o.Before != nil { + query["before"] = o.Before.Format(time.RFC3339) + } + if o.CreatedBy != "" { + query["created_by"] = o.CreatedBy + } + if o.AssignedBy != "" { + query["assigned_by"] = o.AssignedBy + } + if o.MentionedBy != "" { + query["mentioned_by"] = o.MentionedBy + } + if len(query) == 0 { + return nil + } + return query +} + // AttachmentUploadOptions controls metadata sent when uploading an attachment. // // Usage: @@ -201,15 +276,15 @@ 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) ([]types.Issue, error) { +func (s *IssueService) ListIssues(ctx context.Context, owner, repo string, filters ...IssueListOptions) ([]types.Issue, error) { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues", pathParams("owner", owner, "repo", repo)) - return ListAll[types.Issue](ctx, s.client, path, 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) iter.Seq2[types.Issue, error] { +func (s *IssueService) IterIssues(ctx context.Context, owner, repo string, filters ...IssueListOptions) 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, nil) + return ListIter[types.Issue](ctx, s.client, path, issueListQuery(filters...)) } // CreateIssue creates a new issue in a repository. @@ -454,6 +529,19 @@ 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 { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} + func attachmentUploadQuery(opts *AttachmentUploadOptions) map[string]string { if opts == nil { return nil diff --git a/issues_test.go b/issues_test.go index 6d65335..38f8f21 100644 --- a/issues_test.go +++ b/issues_test.go @@ -78,6 +78,64 @@ func TestIssueService_List_Good(t *testing.T) { } } +func TestIssueService_ListFiltered_Good(t *testing.T) { + since := time.Date(2026, time.March, 1, 12, 30, 0, 0, time.UTC) + before := time.Date(2026, time.March, 2, 12, 30, 0, 0, time.UTC) + + 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) + http.NotFound(w, r) + return + } + want := map[string]string{ + "state": "open", + "labels": "bug,help wanted", + "q": "panic", + "type": "issues", + "milestones": "v1.0", + "since": since.Format(time.RFC3339), + "before": before.Format(time.RFC3339), + "created_by": "alice", + "assigned_by": "bob", + "mentioned_by": "carol", + "page": "1", + "limit": "50", + } + for key, wantValue := range want { + if got := r.URL.Query().Get(key); got != wantValue { + t.Errorf("got %s=%q, want %q", key, got, wantValue) + } + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Issue{{ID: 1, Title: "panic in parser"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.ListIssues(context.Background(), "core", "go-forge", IssueListOptions{ + State: "open", + Labels: "bug,help wanted", + Query: "panic", + Type: "issues", + Milestones: "v1.0", + Since: &since, + Before: &before, + CreatedBy: "alice", + AssignedBy: "bob", + MentionedBy: "carol", + }) + if err != nil { + t.Fatal(err) + } + if len(issues) != 1 || issues[0].Title != "panic in parser" { + t.Fatalf("got %#v", issues) + } +} + func TestIssueService_Get_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.go b/pulls.go index 895d9b3..7916a34 100644 --- a/pulls.go +++ b/pulls.go @@ -3,6 +3,8 @@ package forge import ( "context" "iter" + "net/url" + "strconv" "dappco.re/go/core/forge/types" ) @@ -17,6 +19,53 @@ type PullService struct { Resource[types.PullRequest, types.CreatePullRequestOption, types.EditPullRequestOption] } +// PullListOptions controls filtering for repository pull request listings. +// +// Usage: +// +// opts := forge.PullListOptions{State: "open", Labels: []int64{1, 2}} +type PullListOptions struct { + State string + Sort string + Milestone int64 + Labels []int64 + Poster string +} + +// String returns a safe summary of the pull request list filters. +func (o PullListOptions) String() string { + return optionString("forge.PullListOptions", + "state", o.State, + "sort", o.Sort, + "milestone", o.Milestone, + "labels", o.Labels, + "poster", o.Poster, + ) +} + +// 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) { + if o.State != "" { + values.Set("state", o.State) + } + if o.Sort != "" { + values.Set("sort", o.Sort) + } + if o.Milestone != 0 { + values.Set("milestone", strconv.FormatInt(o.Milestone, 10)) + } + for _, label := range o.Labels { + if label != 0 { + values.Add("labels", strconv.FormatInt(label, 10)) + } + } + if o.Poster != "" { + values.Set("poster", o.Poster) + } +} + func newPullService(c *Client) *PullService { return &PullService{ Resource: *NewResource[types.PullRequest, types.CreatePullRequestOption, types.EditPullRequestOption]( @@ -26,15 +75,13 @@ func newPullService(c *Client) *PullService { } // ListPullRequests returns all pull requests in a repository. -func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string) ([]types.PullRequest, error) { - path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) - return ListAll[types.PullRequest](ctx, s.client, path, nil) +func (s *PullService) ListPullRequests(ctx context.Context, owner, repo string, filters ...PullListOptions) ([]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) iter.Seq2[types.PullRequest, error] { - path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) - return ListIter[types.PullRequest](ctx, s.client, path, nil) +func (s *PullService) IterPullRequests(ctx context.Context, owner, repo string, filters ...PullListOptions) iter.Seq2[types.PullRequest, error] { + return s.listIter(ctx, owner, repo, filters...) } // CreatePullRequest creates a pull request in a repository. @@ -176,6 +223,85 @@ func (s *PullService) DeleteReview(ctx context.Context, owner, repo string, inde return s.client.Delete(ctx, path) } +func (s *PullService) listPage(ctx context.Context, owner, repo string, opts ListOptions, filters ...PullListOptions) (*PagedResult[types.PullRequest], error) { + if opts.Page < 1 { + opts.Page = 1 + } + if opts.Limit < 1 { + opts.Limit = defaultPageLimit + } + + path := ResolvePath("/api/v1/repos/{owner}/{repo}/pulls", pathParams("owner", owner, "repo", repo)) + u, err := url.Parse(path) + if err != nil { + return nil, 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() + + var items []types.PullRequest + resp, err := s.client.doJSON(ctx, "GET", u.String(), nil, &items) + if err != nil { + return nil, err + } + + totalCount, _ := strconv.Atoi(resp.Header.Get("X-Total-Count")) + return &PagedResult[types.PullRequest]{ + Items: items, + TotalCount: totalCount, + Page: opts.Page, + HasMore: (totalCount > 0 && (opts.Page-1)*opts.Limit+len(items) < totalCount) || + (totalCount == 0 && len(items) >= opts.Limit), + }, nil +} + +func (s *PullService) listAll(ctx context.Context, owner, repo string, filters ...PullListOptions) ([]types.PullRequest, error) { + var all []types.PullRequest + page := 1 + + for { + result, err := s.listPage(ctx, owner, repo, ListOptions{Page: page, Limit: defaultPageLimit}, filters...) + if err != nil { + return nil, err + } + all = append(all, result.Items...) + if !result.HasMore { + break + } + page++ + } + + return all, nil +} + +func (s *PullService) listIter(ctx context.Context, owner, repo string, filters ...PullListOptions) 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...) + if err != nil { + yield(*new(types.PullRequest), err) + return + } + for _, item := range result.Items { + if !yield(item, nil) { + return + } + } + if !result.HasMore { + break + } + page++ + } + } +} + // 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_test.go b/pulls_test.go index 79571fc..7569d3f 100644 --- a/pulls_test.go +++ b/pulls_test.go @@ -5,6 +5,7 @@ import ( json "github.com/goccy/go-json" "net/http" "net/http/httptest" + "reflect" "testing" "dappco.re/go/core/forge/types" @@ -41,6 +42,53 @@ func TestPullService_List_Good(t *testing.T) { } } +func TestPullService_ListFiltered_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) + http.NotFound(w, r) + return + } + want := map[string]string{ + "state": "open", + "sort": "priority", + "milestone": "7", + "poster": "alice", + "page": "1", + "limit": "50", + } + for key, wantValue := range want { + if got := r.URL.Query().Get(key); got != wantValue { + t.Errorf("got %s=%q, want %q", key, got, wantValue) + } + } + if got := r.URL.Query()["labels"]; !reflect.DeepEqual(got, []string{"1", "2"}) { + t.Errorf("got labels=%v, want %v", got, []string{"1", "2"}) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.PullRequest{{ID: 1, Title: "add feature"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + prs, err := f.Pulls.ListPullRequests(context.Background(), "core", "go-forge", PullListOptions{ + State: "open", + Sort: "priority", + Milestone: 7, + Labels: []int64{1, 2}, + Poster: "alice", + }) + if err != nil { + t.Fatal(err) + } + if len(prs) != 1 || prs[0].Title != "add feature" { + t.Fatalf("got %#v", prs) + } +} + func TestPullService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/releases.go b/releases.go index 5e58496..906d259 100644 --- a/releases.go +++ b/releases.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "strconv" goio "io" @@ -19,6 +20,46 @@ type ReleaseService struct { Resource[types.Release, types.CreateReleaseOption, types.EditReleaseOption] } +// ReleaseListOptions controls filtering for repository release listings. +// +// Usage: +// +// opts := forge.ReleaseListOptions{Draft: true, Query: "1.0"} +type ReleaseListOptions struct { + Draft bool + PreRelease bool + Query string +} + +// String returns a safe summary of the release list filters. +func (o ReleaseListOptions) String() string { + return optionString("forge.ReleaseListOptions", + "draft", o.Draft, + "pre-release", o.PreRelease, + "q", o.Query, + ) +} + +// GoString returns a safe Go-syntax summary of the release list filters. +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) + } + if o.PreRelease { + query["pre-release"] = strconv.FormatBool(true) + } + if o.Query != "" { + query["q"] = o.Query + } + if len(query) == 0 { + return nil + } + return query +} + // ReleaseAttachmentUploadOptions controls metadata sent when uploading a release attachment. // // Usage: @@ -60,15 +101,15 @@ func newReleaseService(c *Client) *ReleaseService { } // ListReleases returns all releases in a repository. -func (s *ReleaseService) ListReleases(ctx context.Context, owner, repo string) ([]types.Release, error) { +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)) - return ListAll[types.Release](ctx, s.client, path, nil) + return ListAll[types.Release](ctx, s.client, path, releaseListQuery(filters...)) } // IterReleases returns an iterator over all releases in a repository. -func (s *ReleaseService) IterReleases(ctx context.Context, owner, repo string) iter.Seq2[types.Release, error] { +func (s *ReleaseService) IterReleases(ctx context.Context, owner, repo string, filters ...ReleaseListOptions) iter.Seq2[types.Release, error] { path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases", pathParams("owner", owner, "repo", repo)) - return ListIter[types.Release](ctx, s.client, path, nil) + return ListIter[types.Release](ctx, s.client, path, releaseListQuery(filters...)) } // CreateRelease creates a release in a repository. @@ -174,3 +215,16 @@ func (s *ReleaseService) DeleteAsset(ctx context.Context, owner, repo string, re path := ResolvePath("/api/v1/repos/{owner}/{repo}/releases/{releaseID}/assets/{assetID}", pathParams("owner", owner, "repo", repo, "releaseID", int64String(releaseID), "assetID", int64String(assetID))) return s.client.Delete(ctx, path) } + +func releaseListQuery(filters ...ReleaseListOptions) map[string]string { + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + return nil + } + return query +} diff --git a/releases_test.go b/releases_test.go index cfe8380..8d5ca7d 100644 --- a/releases_test.go +++ b/releases_test.go @@ -84,6 +84,47 @@ func TestReleaseService_List_Good(t *testing.T) { } } +func TestReleaseService_ListFiltered_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) + http.NotFound(w, r) + return + } + want := map[string]string{ + "draft": "true", + "pre-release": "true", + "q": "1.0", + "page": "1", + "limit": "50", + } + for key, wantValue := range want { + if got := r.URL.Query().Get(key); got != wantValue { + t.Errorf("got %s=%q, want %q", key, got, wantValue) + } + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.Release{{ID: 1, TagName: "v1.0.0", Title: "Release 1.0"}}) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + releases, err := f.Releases.ListReleases(context.Background(), "core", "go-forge", ReleaseListOptions{ + Draft: true, + PreRelease: true, + Query: "1.0", + }) + if err != nil { + t.Fatal(err) + } + if len(releases) != 1 || releases[0].TagName != "v1.0.0" { + t.Fatalf("got %#v", releases) + } +} + func TestReleaseService_Get_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet {