refactor: drop the URL-based error classifier and hint lookups

Errors are named by the service call that failed: Classify,
NotFoundError and ConflictError at the call site. Remove the endpoint
parser that guessed the resource from the request path, and the
follow-up reads the explainer made at error time (repository state,
issue-vs-pull-request, the existing pull request's URL) together with
the credentialed transport each API error carried for them.

Calls that the endpoint parser covered and the services did not now
classify themselves: issues, pull requests, reviews, comments,
releases, branches, wiki pages, repositories, organizations and SSH
keys. Unclassified failures fall back to the generic
"not found (HTTP 404 GET ...)" text.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 10:40:45 +02:00
1 parent ea13c9e4f5
commit 27e39ec4be
20 files changed
+129 -495

No files matched your search

-167
View File
@@ -212,170 +212,3 @@ func refusal(apiErr *transport.APIError) string {
}
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) != "" && at(2) == "teams":
return Resource{Kind: "organization", Key: strconv.Quote(at(1)), Collection: "team"}
case at(0) == "orgs" && at(1) != "":
return Resource{Kind: "organization", Key: strconv.Quote(at(1))}
case at(0) == "orgs":
return Resource{Collection: "organization"}
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) == "admin" && at(1) == "users":
return Resource{Collection: "user"}
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) == "keys":
return Resource{Collection: "SSH key"}
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
}