From 23d879f235def93cf4b46250887c0f8131c5f712 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:31:21 +0000 Subject: [PATCH] feat(issues): add global issue search Add typed wrappers for Forgejo's global issue search endpoint, including paged, all-pages, and iterator variants. Co-Authored-By: Virgil --- issues.go | 88 +++++++++++++++++++++++++++++ issues_test.go | 148 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) diff --git a/issues.go b/issues.go index e8b3d16..d7e8a52 100644 --- a/issues.go +++ b/issues.go @@ -3,6 +3,7 @@ package forge import ( "context" "iter" + "strconv" "time" "dappco.re/go/core/forge/types" @@ -26,6 +27,93 @@ func newIssueService(c *Client) *IssueService { } } +// SearchIssuesOptions controls filtering for the global issue search endpoint. +type SearchIssuesOptions struct { + State string + Labels string + Milestones string + Query string + PriorityRepoID int64 + Type string + Since *time.Time + Before *time.Time + Assigned bool + Created bool + Mentioned bool + ReviewRequested bool + Reviewed bool + Owner string + Team string +} + +func (o SearchIssuesOptions) queryParams() map[string]string { + query := make(map[string]string, 12) + if o.State != "" { + query["state"] = o.State + } + if o.Labels != "" { + query["labels"] = o.Labels + } + if o.Milestones != "" { + query["milestones"] = o.Milestones + } + if o.Query != "" { + query["q"] = o.Query + } + if o.PriorityRepoID != 0 { + query["priority_repo_id"] = strconv.FormatInt(o.PriorityRepoID, 10) + } + if o.Type != "" { + query["type"] = o.Type + } + if o.Since != nil { + query["since"] = o.Since.Format(time.RFC3339) + } + if o.Before != nil { + query["before"] = o.Before.Format(time.RFC3339) + } + if o.Assigned { + query["assigned"] = strconv.FormatBool(true) + } + if o.Created { + query["created"] = strconv.FormatBool(true) + } + if o.Mentioned { + query["mentioned"] = strconv.FormatBool(true) + } + if o.ReviewRequested { + query["review_requested"] = strconv.FormatBool(true) + } + if o.Reviewed { + query["reviewed"] = strconv.FormatBool(true) + } + if o.Owner != "" { + query["owner"] = o.Owner + } + if o.Team != "" { + query["team"] = o.Team + } + if len(query) == 0 { + return nil + } + return query +} + +// SearchIssuesPage returns a single page of issues matching the search filters. +func (s *IssueService) SearchIssuesPage(ctx context.Context, opts SearchIssuesOptions, pageOpts ListOptions) (*PagedResult[types.Issue], error) { + return ListPage[types.Issue](ctx, s.client, "/api/v1/repos/issues/search", opts.queryParams(), pageOpts) +} + +// SearchIssues returns all issues matching the search filters. +func (s *IssueService) SearchIssues(ctx context.Context, opts SearchIssuesOptions) ([]types.Issue, error) { + return ListAll[types.Issue](ctx, s.client, "/api/v1/repos/issues/search", opts.queryParams()) +} + +// IterSearchIssues returns an iterator over issues matching the search filters. +func (s *IssueService) IterSearchIssues(ctx context.Context, opts SearchIssuesOptions) iter.Seq2[types.Issue, error] { + return ListIter[types.Issue](ctx, s.client, "/api/v1/repos/issues/search", opts.queryParams()) +} + // Pin pins an issue. func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) error { path := ResolvePath("/api/v1/repos/{owner}/{repo}/issues/{index}/pin", pathParams("owner", owner, "repo", repo, "index", int64String(index))) diff --git a/issues_test.go b/issues_test.go index 39aa611..ebd2967 100644 --- a/issues_test.go +++ b/issues_test.go @@ -143,6 +143,154 @@ func TestIssueService_Delete_Good(t *testing.T) { } } +func TestIssueService_SearchIssuesPage_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/issues/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + want := map[string]string{ + "state": "open", + "labels": "bug,help wanted", + "milestones": "v1.0", + "q": "panic", + "priority_repo_id": "42", + "type": "issues", + "since": since.Format(time.RFC3339), + "before": before.Format(time.RFC3339), + "assigned": "true", + "created": "true", + "mentioned": "true", + "review_requested": "true", + "reviewed": "true", + "owner": "core", + "team": "platform", + "page": "2", + "limit": "25", + } + 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", "100") + json.NewEncoder(w).Encode([]types.Issue{ + {ID: 1, Title: "panic in parser"}, + {ID: 2, Title: "panic in generator"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + page, err := f.Issues.SearchIssuesPage(context.Background(), SearchIssuesOptions{ + State: "open", + Labels: "bug,help wanted", + Milestones: "v1.0", + Query: "panic", + PriorityRepoID: 42, + Type: "issues", + Since: &since, + Before: &before, + Assigned: true, + Created: true, + Mentioned: true, + ReviewRequested: true, + Reviewed: true, + Owner: "core", + Team: "platform", + }, ListOptions{Page: 2, Limit: 25}) + if err != nil { + t.Fatal(err) + } + if got, want := len(page.Items), 2; got != want { + t.Fatalf("got %d items, want %d", got, want) + } + if !page.HasMore { + t.Fatalf("expected HasMore to be true") + } + if page.TotalCount != 100 { + t.Fatalf("got total count %d, want 100", page.TotalCount) + } + if page.Items[0].Title != "panic in parser" { + t.Fatalf("got first title %q", page.Items[0].Title) + } +} + +func TestIssueService_SearchIssues_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/issues/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "panic" { + t.Errorf("got q=%q, want %q", got, "panic") + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.Issue{ + {ID: 1, Title: "panic in parser"}, + {ID: 2, Title: "panic in generator"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + issues, err := f.Issues.SearchIssues(context.Background(), SearchIssuesOptions{Query: "panic"}) + if err != nil { + t.Fatal(err) + } + if got, want := len(issues), 2; got != want { + t.Fatalf("got %d items, want %d", got, want) + } + if issues[1].Title != "panic in generator" { + t.Fatalf("got second title %q", issues[1].Title) + } +} + +func TestIssueService_IterSearchIssues_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/issues/search" { + t.Errorf("wrong path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("q"); got != "panic" { + t.Errorf("got q=%q, want %q", got, "panic") + } + 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") + var seen []types.Issue + for issue, err := range f.Issues.IterSearchIssues(context.Background(), SearchIssuesOptions{Query: "panic"}) { + if err != nil { + t.Fatal(err) + } + seen = append(seen, issue) + } + if got, want := len(seen), 1; got != want { + t.Fatalf("got %d items, want %d", got, want) + } + if seen[0].Title != "panic in parser" { + t.Fatalf("got title %q", seen[0].Title) + } +} + func TestIssueService_CreateComment_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost {