mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
234 lines
7.6 KiB
Go
234 lines
7.6 KiB
Go
// 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
|
|
}
|
|
|
|
// loadTogether runs the loaders concurrently and returns their errors joined.
|
|
func loadTogether(loaders ...func() error) error {
|
|
return concurrently(len(loaders), func(i int) error { return loaders[i]() })
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|