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 <virgil@lethean.io>
This commit is contained in:
parent
4fc0484669
commit
23d879f235
2 changed files with 236 additions and 0 deletions
88
issues.go
88
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)))
|
||||
|
|
|
|||
148
issues_test.go
148
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue