feat(issue): list issues without an assignee with --no-assignee

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 17:17:11 +02:00
1 parent bae885a4d2
commit 3a61db6533
7 files changed
+128 -44

No files matched your search

+40
View File
@@ -5,6 +5,7 @@ package services
import (
"context"
"errors"
"fmt"
"maps"
"net/http"
@@ -48,6 +49,45 @@ func listAll[T any](fetch func(page int) ([]T, error)) ([]T, error) {
return nil, fmt.Errorf("listing exceeded %d pages", maxListPages)
}
// filterPageSize is the page size filteredPage reads, Gitea's default maximum.
const filterPageSize = 50
// filteredPage applies a filter without a server equivalent: it reads pages of filterPageSize
// from fetch only until one item past the requested page is kept, and counts the matches when
// it reads them all.
func filteredPage[T any](limit, page int, fetch func(limit, page int) (Page[T], error), keep func(T) bool) (Page[T], error) {
if limit <= 0 || page <= 0 {
return Page[T]{}, errors.New("limit and page must be positive")
}
matching, wanted := []T{}, page*limit+1
for serverPage := 1; ; serverPage++ {
if serverPage > maxListPages {
return Page[T]{}, fmt.Errorf("listing exceeded %d pages", maxListPages)
}
window, err := fetch(filterPageSize, serverPage)
if err != nil {
return Page[T]{}, err
}
for _, item := range window.Items {
if keep(item) {
matching = append(matching, item)
}
}
if len(matching) >= wanted {
items, err := pageOf(matching, limit, page)
return Page[T]{Items: items, More: true}, err
}
if len(window.Items) == 0 || (!window.More && len(window.Items) < filterPageSize) {
break
}
}
items, err := pageOf(matching, limit, page)
if err != nil {
return Page[T]{}, err
}
return Page[T]{Items: items, More: page*limit < len(matching), Total: len(matching), Counted: true}, nil
}
// pageHints reads whether the server advertises a next page and the collection size.
func pageHints(headers http.Header) (next bool, total int, counted bool) {
links, err := nextPageLinks(headers.Values("Link"))
+16 -7
View File
@@ -83,6 +83,9 @@ type ListOptions struct {
Assignee, Labels, Milestones, Kind string
Author, Mentions string
Since, Before *time.Time
// NoAssignee keeps only issues without assignees. Gitea has no such filter, so ListPage
// applies it to every issue it reads.
NoAssignee bool
}
// Issues implements repository issue operations.
@@ -121,14 +124,20 @@ func (s Issues) ListPage(ctx context.Context, opts ListOptions) (Page[Issue], er
if opts.Before != nil {
query.Before = *opts.Before
}
issues, err := callPage(ctx, s.Client, opts.Limit, opts.Page, func(ctx context.Context, c *gitea.Client, list gitea.ListOptions) ([]*gitea.Issue, *gitea.Response, error) {
query.ListOptions = list
return c.Issues.ListRepoIssues(ctx, owner, repo, query)
})
if err != nil {
return Page[Issue]{}, err
fetch := func(limit, page int) (Page[Issue], error) {
issues, err := callPage(ctx, s.Client, limit, page, func(ctx context.Context, c *gitea.Client, list gitea.ListOptions) ([]*gitea.Issue, *gitea.Response, error) {
query.ListOptions = list
return c.Issues.ListRepoIssues(ctx, owner, repo, query)
})
if err != nil {
return Page[Issue]{}, err
}
return convertPage(issues, discussionFrom), nil
}
return convertPage(issues, discussionFrom), nil
if opts.NoAssignee {
return filteredPage(opts.Limit, opts.Page, fetch, func(item Issue) bool { return len(item.Assignees) == 0 })
}
return fetch(opts.Limit, opts.Page)
}
// filterValues splits a comma-separated filter, which the SDK joins again.
+10 -35
View File
@@ -143,15 +143,16 @@ type PullListOptions struct {
Limit, Page int
Milestone int64
Labels []int64
// Draft, Head (BRANCH or OWNER:BRANCH), Assignee, Search (text in the title or body) and the
// merged state have no server filter; List applies them to every pull request it reads.
Draft bool
// Draft, Head (BRANCH or OWNER:BRANCH), Assignee, NoAssignee, Search (text in the title or
// body) and the merged state have no server filter; List applies them to every pull request
// it reads.
Draft, NoAssignee bool
Head, Assignee, Search string
}
// filtered reports whether a filter needs every pull request read.
func (o PullListOptions) filtered() bool {
return o.State == "merged" || o.Draft || o.Head != "" || o.Assignee != "" || o.Search != ""
return o.State == "merged" || o.Draft || o.NoAssignee || o.Head != "" || o.Assignee != "" || o.Search != ""
}
// matches applies the filters without a server equivalent to item.
@@ -165,6 +166,7 @@ func (o PullListOptions) matches(item Pull) bool {
return (o.State != "merged" || item.Merged) && (!o.Draft || item.Draft) &&
(branch == "" || (item.Head == branch && (owner == "" || strings.EqualFold(owner, headOwner)))) &&
(o.Assignee == "" || slices.ContainsFunc(item.Assignees, func(name string) bool { return strings.EqualFold(name, o.Assignee) })) &&
(!o.NoAssignee || len(item.Assignees) == 0) &&
(search == "" || strings.Contains(strings.ToLower(item.Title), search) || strings.Contains(strings.ToLower(item.Body), search))
}
@@ -182,41 +184,14 @@ func (s Pulls) ListPage(ctx context.Context, options PullListOptions) (Page[Pull
if !options.filtered() {
return s.listPage(ctx, options)
}
if options.Limit <= 0 || options.Page <= 0 {
return Page[Pull]{}, errors.New("limit and page must be positive")
}
query := options
if query.State == "merged" {
query.State = "closed"
}
matching, wanted := []Pull{}, options.Page*options.Limit+1
for page := 1; ; page++ {
if page > maxListPages {
return Page[Pull]{}, fmt.Errorf("listing exceeded %d pages", maxListPages)
}
query.Limit, query.Page = 50, page
window, err := s.listPage(ctx, query)
if err != nil {
return Page[Pull]{}, err
}
for _, item := range window.Items {
if options.matches(item) {
matching = append(matching, item)
}
}
if len(matching) >= wanted {
items, err := pageOf(matching, options.Limit, options.Page)
return Page[Pull]{Items: items, More: true}, err
}
if len(window.Items) == 0 || (!window.More && len(window.Items) < query.Limit) {
break
}
}
items, err := pageOf(matching, options.Limit, options.Page)
if err != nil {
return Page[Pull]{}, err
}
return Page[Pull]{Items: items, More: options.Page*options.Limit < len(matching), Total: len(matching), Counted: true}, nil
return filteredPage(options.Limit, options.Page, func(limit, page int) (Page[Pull], error) {
query.Limit, query.Page = limit, page
return s.listPage(ctx, query)
}, options.matches)
}
func (s Pulls) listPage(ctx context.Context, options PullListOptions) (Page[Pull], error) {
+17
View File
@@ -363,3 +363,20 @@ func TestPullClientFiltersCountWhenEveryPullWasRead(t *testing.T) {
require.True(t, page.Counted)
require.Equal(t, 2, page.Total)
}
func TestPullNoAssigneeKeepsUnassignedPulls(t *testing.T) {
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Empty(t, r.URL.Query().Get("assignee"))
if r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`[]`))
return
}
_, _ = w.Write([]byte(`[{"number":1,"assignees":[{"login":"bob"}]},{"number":2},{"number":3,"assignees":[]}]`))
}))
page, err := (Pulls{Client: client, Repository: "alice/project"}).ListPage(context.Background(), PullListOptions{State: "open", NoAssignee: true, Limit: 30, Page: 1})
require.NoError(t, err)
require.Len(t, page.Items, 2)
require.EqualValues(t, 2, page.Items[0].Number)
require.EqualValues(t, 3, page.Items[1].Number)
require.True(t, page.Counted)
}