// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package services import ( "context" "errors" "fmt" "maps" "net/http" "net/url" "strconv" "strings" "time" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" ) // maxListPages bounds exhaustive listings so a misbehaving server cannot loop forever. const maxListPages = 100 // pageOf applies CLI pagination to a collection the API returns unpaginated. func pageOf[T any](items []T, limit, page int) ([]T, error) { if limit < 1 || page < 1 { return nil, fmt.Errorf("limit and page must be positive") } if page-1 > len(items)/limit { return []T{}, nil } start := (page - 1) * limit return items[start:min(start+limit, len(items))], nil } // listAll collects pages until the server returns an empty one. func listAll[T any](fetch func(page int) ([]T, error)) ([]T, error) { result := []T{} for page := 1; page <= maxListPages; page++ { items, err := fetch(page) if err != nil { return nil, err } if len(items) == 0 { return result, nil } result = append(result, items...) } 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")) total, countErr := strconv.Atoi(headers.Get("X-Total-Count")) return err == nil && len(links) > 0, total, countErr == nil && total >= 0 } // Page is one window of a collection and what the server reported about the rest of it. type Page[T any] struct { Items []T // More reports whether the collection continues past Items. More bool // Total is the size of the whole collection when Counted, from X-Total-Count. Total int Counted bool } // listPage is listWindow that also reports the collection size the server counted. func listPage[T any](limit, page int, fetch func(limit, page int) ([]T, http.Header, error)) (Page[T], error) { items, headers, err := fetch(limit, page) if err != nil { return Page[T]{}, err } next, total, counted := pageHints(headers) size := len(items) if size == 0 || size >= limit { return Page[T]{Items: items, More: next || (counted && total > page*limit), Total: total, Counted: counted}, nil } // A short final page has no next link and accounts for the whole remainder; anything else // means the server capped the page size. Pages of the returned size are never capped again. if !next && (!counted || total == (page-1)*limit+size) { return Page[T]{Items: items, Total: total, Counted: counted}, nil } offset := (page - 1) * limit result := make([]T, 0, limit+size) for serverPage := offset/size + 1; ; serverPage++ { batch := items if serverPage != page { if batch, headers, err = fetch(size, serverPage); err != nil { return Page[T]{}, err } } result = append(result, batch[min(len(batch), max(0, offset-(serverPage-1)*size)):]...) if len(batch) < size || len(result) >= limit { break } } next, total, counted = pageHints(headers) more := len(result) > limit || next || (counted && total > offset+limit) return Page[T]{Items: result[:min(len(result), limit)], More: more, Total: total, Counted: counted}, nil } // callPage is listPage over an SDK list call. func callPage[T any](ctx context.Context, client *transport.Client, limit, page int, list func(context.Context, *gitea.Client, gitea.ListOptions) ([]T, *gitea.Response, error)) (Page[T], error) { return listPage(limit, page, func(limit, page int) ([]T, http.Header, error) { items, resp, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) ([]T, *gitea.Response, error) { return list(ctx, c, listOptions(limit, page)) }) return items, headerOf(resp), err }) } // rawPage is listPage over a raw JSON collection endpoint; it sets limit and page on query. func rawPage[T any](ctx context.Context, client *transport.Client, endpoint string, query url.Values, limit, page int) (Page[T], error) { query = maps.Clone(query) if query == nil { query = url.Values{} } return listPage(limit, page, func(limit, page int) ([]T, http.Header, error) { query.Set("limit", strconv.Itoa(limit)) query.Set("page", strconv.Itoa(page)) var response []T headers, err := rawJSON(ctx, client, http.MethodGet, endpoint+"?"+query.Encode(), nil, &response) return response, headers, err }) } // NameOrID addresses one kind of resource by name or ID: a numeric value is an ID first, then a // name; any other value is an exact name, then a unique case-insensitive name. type NameOrID[T any] struct { // Kind and Scope name the resources in errors, such as "label" in "alice/project". Kind, Scope string ID func(T) int64 Name func(T) string // ByName matches numeric values as names only, for --by-name. ByName bool } // Find returns the item among items that raw addresses. It fails with a *NotFoundError when // nothing matches and an *AmbiguousError listing the IDs when several names match. func (n NameOrID[T]) Find(items []T, raw string) (T, error) { var zero T raw = strings.TrimSpace(raw) if id, err := strconv.ParseInt(raw, 10, 64); err == nil && !n.ByName { for _, item := range items { if n.ID(item) == id { return item, nil } } } for _, fold := range []bool{false, true} { var matches []T for _, item := range items { name := n.Name(item) if name == raw || (fold && strings.EqualFold(name, raw)) { matches = append(matches, item) } } switch len(matches) { case 0: continue case 1: return matches[0], nil default: ids := make([]int64, len(matches)) for i, match := range matches { ids[i] = n.ID(match) } return zero, &AmbiguousError{Kind: n.Kind, Key: strconv.Quote(raw), Scope: n.Scope, IDs: ids} } } key := strconv.Quote(raw) if _, err := strconv.ParseInt(raw, 10, 64); err == nil && !n.ByName { key = raw } return zero, &NotFoundError{Kind: n.Kind, Key: key, Scope: n.Scope} } func sleepCtx(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } }