diff --git a/internal/services/errors.go b/internal/services/errors.go index e137540c..b5623961 100644 --- a/internal/services/errors.go +++ b/internal/services/errors.go @@ -3,7 +3,367 @@ package services +import ( + "cmp" + "errors" + "net/http" + "regexp" + "slices" + "strconv" + "strings" + + "gitea.dev/tea/internal/transport" +) + // CapabilityError reports an operation absent from the Gitea REST contract. type CapabilityError struct{ Feature, Reason string } func (e *CapabilityError) Error() string { return e.Feature + " is unsupported: " + e.Reason } + +// NotFoundError reports a missing resource by the name the user gave it, such as +// "issue #999 not found in alice/project". +type NotFoundError struct { + // Kind names the resource, such as "issue" or "label". + Kind string + // Key identifies the resource as the user wrote it, such as "#999", "v1.0" or `"bug"`. + Key string + // Scope is the repository or organization that was searched; empty for instance-wide resources. + Scope string + // Err is the underlying failure, usually a 404 *transport.APIError. + Err error +} + +func (e *NotFoundError) Error() string { + text := strings.TrimSpace(e.Kind+" "+e.Key) + " not found" + if e.Scope != "" { + text += " in " + e.Scope + } + return text +} + +func (e *NotFoundError) Unwrap() error { return e.Err } + +// NotFound reports that no kind named key exists in scope. +func NotFound(kind, key, scope string) *NotFoundError { + return &NotFoundError{Kind: kind, Key: key, Scope: scope} +} + +// Reasons a ConflictError gives for refusing a request. +const ( + ReasonArchived = "archived" // the repository is archived and read-only + ReasonExists = "exists" // a resource with the same identity already exists + ReasonMerged = "merged" // the pull request is already merged + ReasonDraft = "draft" // a draft pull request cannot be merged + ReasonOwnReview = "own review" // authors cannot approve or reject their own pull request + ReasonNoChanges = "no changes" // the head branch has no commits the base lacks + ReasonDisabled = "disabled" // the workflow is disabled + ReasonCompleted = "completed" // the run already finished + ReasonKeyInUse = "key in use" // the SSH key is registered already + ReasonOwnsRepos = "owns repositories" // the user still owns repositories +) + +// ConflictError reports a request the current state of a resource refuses, such as closing a +// merged pull request or writing to an archived repository. +type ConflictError struct { + // Reason is one of the Reason constants. + Reason string + // Kind, Key and Scope name the refused resource like NotFoundError; any may be empty. + Kind, Key, Scope string + // Hint is the next step, such as "use tea release edit v1.0"; callers add it. + Hint string + // Err is the underlying *transport.APIError. + Err error +} + +func (e *ConflictError) Error() string { + subject := strings.TrimSpace(e.Kind + " " + e.Key) + var text string + switch e.Reason { + case ReasonArchived: + text = cmp.Or(e.Scope, "the repository") + " is archived (read-only)" + case ReasonExists: + text = cmp.Or(subject, "the resource") + " already exists" + if e.Scope != "" { + text += " in " + e.Scope + } + case ReasonMerged: + text = cmp.Or(subject, "the pull request") + " is already merged" + case ReasonDraft: + text = cmp.Or(subject, "the pull request") + " is a draft" + case ReasonOwnReview: + text = "you can't approve or request changes on your own pull request" + case ReasonNoChanges: + text = "no commits between the base and head branches" + case ReasonDisabled: + text = cmp.Or(subject, "the workflow") + " is disabled" + case ReasonCompleted: + text = cmp.Or(subject, "the run") + " is already completed" + case ReasonKeyInUse: + text = "this SSH key is already registered" + case ReasonOwnsRepos: + text = cmp.Or(subject, "the user") + " still owns repositories" + default: + text = e.Err.Error() + } + if e.Hint != "" { + text += "; " + e.Hint + } + return text +} + +func (e *ConflictError) Unwrap() error { return e.Err } + +// AmbiguousError reports a name that matches several resources. +type AmbiguousError struct { + Kind, Key, Scope string + IDs []int64 +} + +func (e *AmbiguousError) Error() string { + ids := make([]string, len(e.IDs)) + for i, id := range e.IDs { + ids[i] = strconv.FormatInt(id, 10) + } + text := e.Kind + " " + e.Key + " is ambiguous" + if e.Scope != "" { + text += " in " + e.Scope + } + return text + " (IDs " + strings.Join(ids, ", ") + "); pass an ID" +} + +// refusals maps the messages Gitea gives for refused requests onto conflict reasons. Each entry +// lists lowercase message fragments; statuses, when set, limit an entry to those responses. +var refusals = []struct { + reason string + statuses []int + fragments []string +}{ + {ReasonArchived, nil, []string{"is archived"}}, + {ReasonMerged, nil, []string{"already merged"}}, + {ReasonDraft, []int{http.StatusMethodNotAllowed}, []string{"work in progress", "draft"}}, + {ReasonOwnReview, nil, []string{"approve your own pull", "reject your own pull"}}, + {ReasonNoChanges, nil, []string{"no changes between the head and the base"}}, + {ReasonDisabled, nil, []string{"workflow is disabled"}}, + {ReasonCompleted, nil, []string{"already completed"}}, + {ReasonKeyInUse, nil, []string{"key content has been used"}}, + {ReasonOwnsRepos, nil, []string{"ownership of repositories"}}, + {ReasonExists, []int{http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity}, []string{"already exist", "release is has no tag"}}, +} + +var ( + // unknownUser finds the name in Gitea's "user does not exist [uid: 0, name: nobody]". + unknownUser = regexp.MustCompile(`user does not exist \[uid: \d+, name: ([^\]]+)\]`) + // pullTargets finds the branches in "pull request already exists for these targets [... head_branch: x, base_branch: y]". + pullTargets = regexp.MustCompile(`head_branch: ([^,\]]+), base_branch: ([^,\]]+)`) + // wikiTitle finds the page in "wiki page already exists [title: Home.md]". + wikiTitle = regexp.MustCompile(`\[title: (.+?)(?:\.md)?\]`) +) + +// Classify names the resource of a failed request for the user: a 404 becomes a NotFoundError +// for kind key in scope, a refusal Gitea explains (archived repository, merged pull request, +// existing resource, ...) becomes a ConflictError, and an unknown user the request named becomes +// a NotFoundError for that user. Other errors are returned unchanged. +func Classify(err error, kind, key, scope string) error { + apiErr, ok := errors.AsType[*transport.APIError](err) + if !ok { + return err + } + if match := unknownUser.FindStringSubmatch(apiErr.Message); match != nil { + return &NotFoundError{Kind: "user", Key: strconv.Quote(match[1]), Err: err} + } + if reason := refusal(apiErr); reason != "" { + conflict := &ConflictError{Reason: reason, Kind: kind, Key: key, Scope: scope, Err: err} + if reason == ReasonArchived && kind == "repository" { + conflict.Kind, conflict.Key, conflict.Scope = "", "", key + } + if match := pullTargets.FindStringSubmatch(apiErr.Message); reason == ReasonExists && match != nil { + conflict.Kind, conflict.Key = "pull request", "for "+match[1]+" into "+match[2] + } + if match := wikiTitle.FindStringSubmatch(apiErr.Message); reason == ReasonExists && match != nil { + conflict.Kind, conflict.Key = "wiki page", strconv.Quote(match[1]) + } + return conflict + } + if apiErr.Status == http.StatusNotFound && kind != "" { + return &NotFoundError{Kind: kind, Key: key, Scope: scope, Err: err} + } + return err +} + +// refusal returns the conflict reason of a refused request, or "". +func refusal(apiErr *transport.APIError) string { + if apiErr.Status == http.StatusLocked { + return ReasonArchived + } + message := strings.ToLower(apiErr.Message) + for _, refusal := range refusals { + if refusal.statuses != nil && !slices.Contains(refusal.statuses, apiErr.Status) { + continue + } + for _, fragment := range refusal.fragments { + if strings.Contains(message, fragment) { + return refusal.reason + } + } + } + return "" +} + +// ClassifyRequest is Classify for a failure no service classified: it infers the resource from +// the failed request's endpoint, so a 404 for GET /api/v1/repos/alice/x/issues/9 reports +// "issue #9 not found in alice/x". +func ClassifyRequest(err error) error { + apiErr, ok := errors.AsType[*transport.APIError](err) + if !ok { + return err + } + resource := RequestResource(apiErr.Endpoint) + kind, key := resource.Kind, resource.Key + // A refused POST to a collection concerns the resource it would have created. + if apiErr.Status != http.StatusNotFound && resource.Collection != "" { + kind, key = resource.Collection, "" + } + return Classify(err, kind, key, resource.Scope) +} + +// Resource is what a Gitea API endpoint addresses. +type Resource struct { + // Kind and Key name one resource, such as "issue" and "#9"; both are empty for collections. + Kind, Key string + // Collection names the kind of resource a collection endpoint holds, such as "branch" for + // POST /repos/{owner}/{repo}/branches. + Collection string + // Scope is the repository or organization the resource belongs to. + Scope string + // Repository is the owner/name of a repository endpoint, else empty. + Repository string + // Section is the first path segment below the repository, such as "issues" or "pulls". + Section string +} + +// RequestResource reports what endpoint, an API path such as /api/v1/repos/alice/x/issues/9, +// addresses. +func RequestResource(endpoint string) Resource { + _, rest, ok := strings.Cut(endpoint, "/api/v1/") + if !ok { + return Resource{} + } + parts := strings.Split(strings.Trim(rest, "/"), "/") + if len(parts) < 3 || parts[0] != "repos" { + return instanceResource(parts) + } + repo := parts[1] + "/" + parts[2] + if len(parts) == 3 { + return Resource{Kind: "repository", Key: repo, Repository: repo} + } + resource := repositoryResource(parts[3:]) + resource.Repository, resource.Scope, resource.Section = repo, repo, parts[3] + return resource +} + +// collections names the resources that POST to a repository collection creates. +var collections = map[string]string{ + "branches": "branch", "tags": "tag", "releases": "release", "labels": "label", "milestones": "milestone", + "pulls": "pull request", "issues": "issue", "hooks": "webhook", "keys": "deploy key", "wiki": "wiki page", +} + +// repositoryResource matches the path below /repos/{owner}/{repo}. +func repositoryResource(parts []string) Resource { + at := func(i int) string { + if i < len(parts) { + return parts[i] + } + return "" + } + rest := func(i int) string { return strings.Join(parts[min(i, len(parts)):], "/") } + switch { + case at(0) == "issues" && at(1) == "comments" && at(2) != "": + return Resource{Kind: "comment", Key: at(2)} + case (at(0) == "issues" || at(0) == "pulls") && isNumber(at(1)): + kind := "issue" + if at(0) == "pulls" { + kind = "pull request" + } + return Resource{Kind: kind, Key: "#" + at(1)} + case at(0) == "releases" && at(1) == "tags" && at(2) != "": + return Resource{Kind: "release", Key: rest(2)} + case at(0) == "releases" && at(2) == "assets" && at(3) != "": + return Resource{Kind: "release asset", Key: at(3)} + case at(0) == "releases" && isNumber(at(1)): + return Resource{Kind: "release", Key: at(1)} + case at(0) == "labels" && at(1) != "": + return Resource{Kind: "label", Key: at(1)} + case at(0) == "milestones" && at(1) != "": + return Resource{Kind: "milestone", Key: nameKey(at(1))} + case at(0) == "branches" && at(1) != "": + return Resource{Kind: "branch", Key: rest(1)} + case at(0) == "branch_protections" && at(1) != "": + return Resource{Kind: "branch protection", Key: rest(1)} + case at(0) == "tags" && at(1) != "": + return Resource{Kind: "tag", Key: rest(1)} + case at(0) == "wiki" && (at(1) == "page" || at(1) == "revisions") && at(2) != "": + return Resource{Kind: "wiki page", Key: strconv.Quote(at(2))} + case at(0) == "hooks" && isNumber(at(1)): + return Resource{Kind: "webhook", Key: at(1)} + case at(0) == "collaborators" && at(1) != "": + return Resource{Kind: "collaborator", Key: strconv.Quote(at(1))} + case at(0) == "contents" && at(1) != "": + return Resource{Kind: "file", Key: rest(1)} + case at(0) == "actions": + return actionsResource(at(1), at(2)) + } + return Resource{Collection: collections[at(0)]} +} + +func actionsResource(collection, key string) Resource { + kinds := map[string]string{"runs": "run", "jobs": "job", "artifacts": "artifact", "workflows": "workflow", "secrets": "secret", "variables": "variable"} + kind := kinds[collection] + if key == "" { + return Resource{Collection: kind} + } + return Resource{Kind: kind, Key: key} +} + +// instanceResource matches endpoints outside a repository, such as /users/{name}. +func instanceResource(parts []string) Resource { + at := func(i int) string { + if i < len(parts) { + return parts[i] + } + return "" + } + switch { + case at(0) == "orgs" && at(1) != "" && (at(2) == "members" || at(2) == "public_members") && at(3) != "": + return Resource{Kind: "member", Key: strconv.Quote(at(3)), Scope: at(1)} + case at(0) == "orgs" && at(1) != "" && at(2) == "repos": + return Resource{Kind: "organization", Key: strconv.Quote(at(1)), Collection: "repository"} + case at(0) == "orgs" && at(1) != "": + return Resource{Kind: "organization", Key: strconv.Quote(at(1))} + case at(0) == "users" && at(1) != "": + return Resource{Kind: "user", Key: strconv.Quote(at(1))} + case at(0) == "admin" && at(1) == "users" && at(2) != "": + return Resource{Kind: "user", Key: strconv.Quote(at(2))} + case at(0) == "teams" && isNumber(at(1)): + return Resource{Kind: "team", Key: at(1)} + case at(0) == "user" && at(1) == "keys" && at(2) != "": + return Resource{Kind: "SSH key", Key: at(2)} + case at(0) == "user" && at(1) == "repos": + return Resource{Collection: "repository"} + case at(0) == "notifications" && at(1) == "threads" && at(2) != "": + return Resource{Kind: "notification", Key: at(2)} + } + return Resource{} +} + +// nameKey quotes a name, but not a numeric ID. +func nameKey(value string) string { + if isNumber(value) { + return value + } + return strconv.Quote(value) +} + +func isNumber(value string) bool { + n, err := strconv.ParseInt(value, 10, 64) + return err == nil && n > 0 +} diff --git a/internal/services/errors_test.go b/internal/services/errors_test.go new file mode 100644 index 00000000..98aa49ac --- /dev/null +++ b/internal/services/errors_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package services + +import ( + "context" + "errors" + "net/http" + "testing" + + "gitea.dev/tea/internal/transport" + "github.com/stretchr/testify/require" +) + +func TestClassifyRequestNamesTheResource(t *testing.T) { + for _, tc := range []struct { + name, method, endpoint, message string + status int + want string + }{ + {"issue", "GET", "/api/v1/repos/alice/project/issues/999", "not found", 404, "issue #999 not found in alice/project"}, + {"issue comments", "GET", "/api/v1/repos/alice/project/issues/9/comments", "not found", 404, "issue #9 not found in alice/project"}, + {"comment", "DELETE", "/api/v1/repos/alice/project/issues/comments/81", "comment does not exist [id: 81, issue_id: 0]", 404, "comment 81 not found in alice/project"}, + {"pull request", "GET", "/api/v1/repos/alice/project/pulls/7/reviews", "not found", 404, "pull request #7 not found in alice/project"}, + {"release tag", "GET", "/api/v1/repos/alice/project/releases/tags/v9", "not found", 404, "release v9 not found in alice/project"}, + {"branch with slashes", "GET", "/api/v1/repos/alice/project/branches/docs/guide", "not found", 404, "branch docs/guide not found in alice/project"}, + {"milestone title", "GET", "/api/v1/repos/alice/project/milestones/v2.0", "not found", 404, `milestone "v2.0" not found in alice/project`}, + {"milestone id", "GET", "/api/v1/repos/alice/project/milestones/3", "not found", 404, "milestone 3 not found in alice/project"}, + {"wiki page", "GET", "/api/v1/repos/alice/project/wiki/page/Nope", "not found", 404, `wiki page "Nope" not found in alice/project`}, + {"run", "GET", "/api/v1/repos/alice/project/actions/runs/9999/jobs", "run with id 9999: resource does not exist", 404, "run 9999 not found in alice/project"}, + {"secret", "DELETE", "/api/v1/repos/alice/project/actions/secrets/NOPE", "secret was not found [name: ]", 404, "secret NOPE not found in alice/project"}, + {"repository", "GET", "/api/v1/repos/nobody/none", "not found", 404, "repository nobody/none not found"}, + {"user", "GET", "/api/v1/users/nobody/repos", "user redirect does not exist [name: nobody]", 404, `user "nobody" not found`}, + {"organization", "GET", "/api/v1/orgs/nope-org", "not found", 404, `organization "nope-org" not found`}, + {"org member", "DELETE", "/api/v1/orgs/acme/members/carol", "not found", 404, `member "carol" not found in acme`}, + {"team", "GET", "/api/v1/teams/99", "not found", 404, "team 99 not found"}, + {"ssh key", "DELETE", "/api/v1/user/keys/5", "not found", 404, "SSH key 5 not found"}, + {"archived 423", "POST", "/api/v1/repos/alice/old-stuff/issues", "repo is archived", 423, "alice/old-stuff is archived (read-only)"}, + {"archived 500", "POST", "/api/v1/repos/alice/old-stuff/releases", " is archived", 500, "alice/old-stuff is archived (read-only)"}, + {"archived repository edit", "PATCH", "/api/v1/repos/alice/old-stuff", "repo is archived", 423, "alice/old-stuff is archived (read-only)"}, + {"pull request exists", "POST", "/api/v1/repos/alice/x/pulls", "pull request already exists for these targets [id: 16, issue_id: 1, head_repo_id: 44, base_repo_id: 44, head_branch: feat, base_branch: main]", 409, "pull request for feat into main already exists in alice/x"}, + {"no changes", "POST", "/api/v1/repos/alice/x/pulls", "Invalid PullRequest: There are no changes between the head and the base", 422, "no commits between the base and head branches"}, + {"merged", "PATCH", "/api/v1/repos/alice/x/pulls/3", "cannot change state of this pull request, it was already merged", 412, "pull request #3 is already merged"}, + {"merge merged", "POST", "/api/v1/repos/alice/x/pulls/3/merge", "The PR is already merged", 405, "pull request #3 is already merged"}, + {"draft merge", "POST", "/api/v1/repos/alice/x/pulls/5/merge", "Work in progress PRs cannot be merged", 405, "pull request #5 is a draft"}, + {"own approval", "POST", "/api/v1/repos/alice/x/pulls/1/reviews", "approve your own pull is not allowed", 422, "you can't approve or request changes on your own pull request"}, + {"own rejection", "POST", "/api/v1/repos/alice/x/pulls/1/reviews", "reject your own pull is not allowed", 422, "you can't approve or request changes on your own pull request"}, + {"branch exists", "POST", "/api/v1/repos/alice/x/branches", "The branch already exists.", 409, "branch already exists in alice/x"}, + {"release exists", "POST", "/api/v1/repos/alice/x/releases", "Release is has no Tag", 409, "release already exists in alice/x"}, + {"wiki page exists", "POST", "/api/v1/repos/alice/x/wiki/new", "wiki page already exists [title: Home.md]", 400, `wiki page "Home" already exists in alice/x`}, + {"repository exists", "POST", "/api/v1/user/repos", "The repository with the same name already exists.", 409, "repository already exists"}, + {"org repository exists", "POST", "/api/v1/orgs/acme/repos", "The repository with the same name already exists.", 409, "repository already exists"}, + {"run completed", "POST", "/api/v1/repos/alice/x/actions/runs/35/cancel", "run is already completed", 409, "run 35 is already completed"}, + {"workflow disabled", "POST", "/api/v1/repos/alice/x/actions/workflows/nightly.yaml/dispatches", "workflow is disabled", 403, "workflow nightly.yaml is disabled"}, + {"key in use", "POST", "/api/v1/user/keys", "Key content has been used as non-deploy key", 422, "this SSH key is already registered"}, + {"unknown assignee", "POST", "/api/v1/repos/alice/x/issues", "Assignee does not exist: [name: user does not exist [uid: 0, name: nobody123]]", 422, `user "nobody123" not found`}, + {"unknown poster filter", "GET", "/api/v1/repos/alice/x/pulls", "user does not exist [uid: 0, name: nobody]", 400, `user "nobody" not found`}, + {"unrecognized endpoint", "GET", "/api/v1/repos/alice/x/issues", "not found", 404, "not found (HTTP 404 GET /api/v1/repos/alice/x/issues)"}, + {"server error", "GET", "/api/v1/repos/alice/x/issues/1", "boom", 500, "Gitea API returned HTTP 500 for GET /api/v1/repos/alice/x/issues/1: boom"}, + } { + t.Run(tc.name, func(t *testing.T) { + apiErr := &transport.APIError{Status: tc.status, Method: tc.method, Endpoint: tc.endpoint, Message: tc.message} + err := ClassifyRequest(apiErr) + require.EqualError(t, err, tc.want) + require.ErrorIs(t, err, apiErr, "the API error stays reachable for exit codes and --debug") + }) + } +} + +func TestClassifyUsesTheCallersNameForTheResource(t *testing.T) { + apiErr := &transport.APIError{Status: http.StatusNotFound, Method: "GET", Endpoint: "/api/v1/repos/alice/x/releases/5", Message: "not found"} + err := Classify(apiErr, "release", "v1.0", "alice/x") + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) + require.EqualError(t, err, "release v1.0 not found in alice/x") + conflict := Classify(&transport.APIError{Status: http.StatusConflict, Message: "Release is has no Tag"}, "release", "v1.0", "alice/x") + require.EqualError(t, conflict, "release v1.0 already exists in alice/x") + require.Equal(t, errors.New("plain"), Classify(errors.New("plain"), "release", "v1.0", "alice/x")) + require.ErrorIs(t, Classify(context.Canceled, "release", "v1.0", "alice/x"), context.Canceled) +} + +func TestConflictHintsFollowTheReason(t *testing.T) { + err := &ConflictError{Reason: ReasonArchived, Scope: "alice/old", Hint: "unarchive it with: tea repo edit alice/old --archived=false"} + require.EqualError(t, err, "alice/old is archived (read-only); unarchive it with: tea repo edit alice/old --archived=false") +} diff --git a/internal/services/helpers.go b/internal/services/helpers.go index dbf7916d..e1fcba83 100644 --- a/internal/services/helpers.go +++ b/internal/services/helpers.go @@ -6,9 +6,14 @@ package services import ( "context" "fmt" + "maps" "net/http" + "net/url" "strconv" + "strings" "time" + + "gitea.dev/tea/internal/transport" ) // maxListPages bounds exhaustive listings so a misbehaving server cannot loop forever. @@ -49,22 +54,38 @@ func pageHints(headers http.Header) (next bool, total int, counted bool) { 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 +} + // listWindow returns items [(page-1)*limit, page*limit) even when the server caps its page size // below limit (MAX_RESPONSE_ITEMS), and reports whether the collection continues past them. func listWindow[T any](limit, page int, fetch func(limit, page int) ([]T, http.Header, error)) ([]T, bool, error) { + window, err := listPage(limit, page, fetch) + return window.Items, window.More, err +} + +// 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 nil, false, err + return Page[T]{}, err } next, total, counted := pageHints(headers) size := len(items) if size == 0 || size >= limit { - return items, next || (counted && total > page*limit), nil + 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 items, false, nil + return Page[T]{Items: items, Total: total, Counted: counted}, nil } offset := (page - 1) * limit result := make([]T, 0, limit+size) @@ -72,7 +93,7 @@ func listWindow[T any](limit, page int, fetch func(limit, page int) ([]T, http.H batch := items if serverPage != page { if batch, headers, err = fetch(size, serverPage); err != nil { - return nil, false, err + return Page[T]{}, err } } result = append(result, batch[min(len(batch), max(0, offset-(serverPage-1)*size)):]...) @@ -82,7 +103,73 @@ func listWindow[T any](limit, page int, fetch func(limit, page int) ([]T, http.H } next, total, counted = pageHints(headers) more := len(result) > limit || next || (counted && total > offset+limit) - return result[:min(len(result), limit)], more, nil + return Page[T]{Items: result[:min(len(result), limit)], More: more, Total: total, Counted: counted}, nil +} + +// 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 { diff --git a/internal/services/helpers_test.go b/internal/services/helpers_test.go index 15a0f11f..a37a59fc 100644 --- a/internal/services/helpers_test.go +++ b/internal/services/helpers_test.go @@ -12,6 +12,7 @@ import ( "gitea.dev/tea/internal/transport" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // newTestClient serves handler for the duration of the test and returns a client for it. @@ -38,3 +39,39 @@ func requestJSON(t *testing.T, r *http.Request) map[string]any { assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) return payload } + +func TestNameOrIDPrefersIDsThenExactThenFoldedNames(t *testing.T) { + type label struct { + id int64 + name string + } + labels := []label{{11, "bug"}, {12, "Bug"}, {13, "7"}, {7, "seven"}, {20, "kind/feature"}, {21, "dup"}, {22, "dup"}} + lookup := NameOrID[label]{Kind: "label", Scope: "alice/project", ID: func(l label) int64 { return l.id }, Name: func(l label) string { return l.name }} + for _, tc := range []struct { + raw string + byName bool + want int64 + err string + }{ + {raw: "7", want: 7}, + {raw: "7", byName: true, want: 13}, + {raw: "13", want: 13}, + {raw: "bug", want: 11}, + {raw: "BUG", err: `label "BUG" is ambiguous in alice/project (IDs 11, 12); pass an ID`}, + {raw: "KIND/FEATURE", want: 20}, + {raw: " kind/feature ", want: 20}, + {raw: "dup", err: `label "dup" is ambiguous in alice/project (IDs 21, 22); pass an ID`}, + {raw: "nope", err: `label "nope" not found in alice/project`}, + {raw: "99", err: "label 99 not found in alice/project"}, + {raw: "99", byName: true, err: `label "99" not found in alice/project`}, + } { + lookup.ByName = tc.byName + got, err := lookup.Find(labels, tc.raw) + if tc.err != "" { + require.EqualError(t, err, tc.err, tc.raw) + continue + } + require.NoError(t, err, tc.raw) + require.Equal(t, tc.want, got.id, tc.raw) + } +} diff --git a/internal/services/pagination_test.go b/internal/services/pagination_test.go index 1e9ddbb1..bc7a3b7f 100644 --- a/internal/services/pagination_test.go +++ b/internal/services/pagination_test.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "strconv" "strings" "testing" @@ -90,3 +91,30 @@ func TestListsCollectPagesBeyondServerPageSize(t *testing.T) { }) } } + +func TestPagesReportTheCountedTotal(t *testing.T) { + for _, tc := range []struct { + total, maxItems, limit, page int + count, more bool + }{ + {120, 50, 30, 1, true, true}, + {120, 50, 1000, 1, true, false}, + {120, 50, 100, 2, true, false}, + {40, 50, 100, 1, false, false}, + } { + t.Run(fmt.Sprintf("%+v", tc), func(t *testing.T) { + server, _ := cappedServer(t, tc.total, tc.maxItems, true, tc.count) + client := transport.New(server.URL, "", false) + query := url.Values{"state": {"open"}} + window, err := rawPage[Organization](context.Background(), client, "orgs", query, tc.limit, tc.page) + require.NoError(t, err) + require.Len(t, window.Items, min(tc.limit, max(0, tc.total-(tc.page-1)*tc.limit))) + require.Equal(t, tc.more, window.More) + require.Equal(t, tc.count, window.Counted) + if tc.count { + require.Equal(t, tc.total, window.Total) + } + require.Equal(t, url.Values{"state": {"open"}}, query, "the caller's query is not modified") + }) + } +}