mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
feat(services): classify API failures by resource and report page totals
Add NotFoundError, ConflictError and AmbiguousError so commands can report "issue #999 not found in alice/project" or "alice/old-stuff is archived" instead of the transport's status text. Classify wraps a failure with the resource the caller names; ClassifyRequest infers it from the endpoint for failures no service classified, and maps the refusals Gitea explains (archived repository, merged or draft pull request, own review, existing resource, unknown user, disabled workflow, completed run, reused SSH key). Page reports the X-Total-Count that listWindow discarded, NameOrID resolves a resource by ID or name (numeric values are IDs first, ambiguous names list their IDs), and rawPage pages raw collection endpoints. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
5 files changed
+603
-5
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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", "<Repository 5:alice/old-stuff> 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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user