feat(issues): add global issue search
All checks were successful
Security Scan / security (push) Successful in 13s
Test / test (push) Successful in 1m15s

Add typed wrappers for Forgejo's global issue search endpoint, including paged, all-pages, and iterator variants.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 23:31:21 +00:00
parent 4fc0484669
commit 23d879f235
2 changed files with 236 additions and 0 deletions

View file

@ -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)))

View file

@ -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 {