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

+4 -2
View File
@@ -64,9 +64,10 @@ func (s Branches) ListPage(ctx context.Context, repo string, limit, page int) (P
// View retrieves a remote branch.
func (s Branches) View(ctx context.Context, repo, branch string) (Branch, error) {
return repoConvert(ctx, s.Client, repo, branchFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Branch, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, repo, branchFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Branch, *gitea.Response, error) {
return c.Repositories.GetRepoBranch(ctx, owner, name, branch)
})
return item, Classify(err, "branch", branch, repo)
}
// Create creates a branch from source, a branch, tag or commit, or from the default branch.
@@ -98,10 +99,11 @@ func (s Branches) Rename(ctx context.Context, repo, oldName, newName string) err
// Delete removes a remote branch.
func (s Branches) Delete(ctx context.Context, repo, branch string) error {
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
err := repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
_, resp, err := c.Repositories.DeleteRepoBranch(ctx, owner, name, branch)
return resp, err
})
return Classify(err, "branch", branch, repo)
}
// Protection is a branch protection rule.
+8 -4
View File
@@ -71,28 +71,32 @@ func (s Comments) Page(ctx context.Context, n int64, limit, page int) ([]Comment
// View retrieves a comment by its repository-scoped ID.
func (s Comments) View(ctx context.Context, id int64) (Comment, error) {
return repoConvert(ctx, s.Client, s.Repository, commentFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Comment, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, commentFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Comment, *gitea.Response, error) {
return c.Issues.GetIssueComment(ctx, owner, repo, id)
})
return item, Classify(err, "comment", strconv.FormatInt(id, 10), s.Repository)
}
// Create adds a comment to an issue or pull request.
func (s Comments) Create(ctx context.Context, n int64, body string) (Comment, error) {
return repoConvert(ctx, s.Client, s.Repository, commentFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Comment, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, commentFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Comment, *gitea.Response, error) {
return c.Issues.CreateIssueComment(ctx, owner, repo, n, gitea.CreateIssueCommentOption{Body: body})
})
return item, Classify(err, "issue", "#"+strconv.FormatInt(n, 10), s.Repository)
}
// Edit replaces the body of a repository comment.
func (s Comments) Edit(ctx context.Context, id int64, body string) (Comment, error) {
return repoConvert(ctx, s.Client, s.Repository, commentFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Comment, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, commentFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Comment, *gitea.Response, error) {
return c.Issues.EditIssueComment(ctx, owner, repo, id, gitea.EditIssueCommentOption{Body: body})
})
return item, Classify(err, "comment", strconv.FormatInt(id, 10), s.Repository)
}
// Delete removes a repository comment.
func (s Comments) Delete(ctx context.Context, id int64) error {
return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
return c.Issues.DeleteIssueComment(ctx, owner, repo, id)
})
return Classify(err, "comment", strconv.FormatInt(id, 10), s.Repository)
}
-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
}
+25 -50
View File
@@ -13,60 +13,35 @@ import (
"github.com/stretchr/testify/require"
)
func TestClassifyRequestNamesTheResource(t *testing.T) {
func TestClassifyWordsRefusals(t *testing.T) {
for _, tc := range []struct {
name, method, endpoint, message string
status int
want string
name, message, kind, key 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"},
{"team exists", "POST", "/api/v1/orgs/acme/teams", "team already exists [org_id: 3, name: devs]", 422, "team already exists"},
{"user exists", "POST", "/api/v1/admin/users", "user already exists [name: bob]", 422, "user already exists"},
{"organization exists", "POST", "/api/v1/orgs", "user already exists [name: acme]", 422, "organization already exists"},
{"archived label is no repository", "POST", "/api/v1/repos/alice/x/issues/1/labels", "label is archived", 422, "Gitea API returned HTTP 422 for POST /api/v1/repos/alice/x/issues/1/labels: label is archived"},
{"run completed", "POST", "/api/v1/repos/alice/x/actions/runs/35/cancel", "run is already completed", 409, "run 35 is already completed"},
{"run not done", "DELETE", "/api/v1/repos/alice/x/actions/runs/37", "this workflow run is not done", 400, "run 37 has not finished"},
{"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"},
{"missing issue", "not found", "issue", "#999", 404, "issue #999 not found in alice/x"},
{"archived 423", "repo is archived", "issue", "", 423, "alice/x is archived (read-only)"},
{"archived 500", "<Repository 5:alice/x> is archived", "release", "", 500, "alice/x is archived (read-only)"},
{"pull request exists", "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]", "pull request", "", 409, "pull request for feat into main already exists in alice/x"},
{"no changes", "Invalid PullRequest: There are no changes between the head and the base", "pull request", "", 422, "no commits between the base and head branches"},
{"merged", "cannot change state of this pull request, it was already merged", "pull request", "#3", 412, "pull request #3 is already merged"},
{"draft merge", "Work in progress PRs cannot be merged", "pull request", "#5", 405, "pull request #5 is a draft"},
{"own approval", "approve your own pull is not allowed", "pull request", "#1", 422, "you can't approve or request changes on your own pull request"},
{"own rejection", "reject your own pull is not allowed", "pull request", "#1", 422, "you can't approve or request changes on your own pull request"},
{"branch exists", "The branch already exists.", "branch", "", 409, "branch already exists in alice/x"},
{"release exists", "Release is has no Tag", "release", "", 409, "release already exists in alice/x"},
{"wiki page exists", "wiki page already exists [title: Home.md]", "wiki page", `"Home"`, 400, `wiki page "Home" already exists in alice/x`},
{"archived label is no repository", "label is archived", "issue", "#1", 422, "Gitea API returned HTTP 422 for POST /api/v1/repos/alice/x/endpoint: label is archived"},
{"run completed", "run is already completed", "run", "35", 409, "run 35 is already completed"},
{"run not done", "this workflow run is not done", "run", "37", 400, "run 37 has not finished"},
{"workflow disabled", "workflow is disabled", "workflow", "nightly.yaml", 403, "workflow nightly.yaml is disabled"},
{"key in use", "Key content has been used as non-deploy key", "SSH key", "", 422, "this SSH key is already registered"},
{"unknown assignee", "Assignee does not exist: [name: user does not exist [uid: 0, name: nobody123]]", "issue", "", 422, `user "nobody123" not found`},
{"server error", "boom", "issue", "#1", 500, "Gitea API returned HTTP 500 for POST /api/v1/repos/alice/x/endpoint: 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)
apiErr := &transport.APIError{Status: tc.status, Method: "POST", Endpoint: "/api/v1/repos/alice/x/endpoint", Message: tc.message}
err := Classify(apiErr, tc.kind, tc.key, "alice/x")
require.EqualError(t, err, tc.want)
require.ErrorIs(t, err, apiErr, "the API error stays reachable for exit codes and --debug")
})
+7 -3
View File
@@ -6,6 +6,7 @@ package services
import (
"context"
"net/http"
"strconv"
"strings"
"time"
@@ -144,19 +145,21 @@ func filterValues(value string) []string {
// View retrieves one issue.
func (s Issues) View(ctx context.Context, number int64) (Issue, error) {
return repoConvert(ctx, s.Client, s.Repository, discussionFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, discussionFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) {
return c.Issues.GetIssue(ctx, owner, repo, number)
})
return item, Classify(err, "issue", "#"+strconv.FormatInt(number, 10), s.Repository)
}
// Create opens an issue.
func (s Issues) Create(ctx context.Context, options IssueOptions) (Issue, error) {
return repoConvert(ctx, s.Client, s.Repository, discussionFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, discussionFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) {
return c.Issues.CreateIssue(ctx, owner, repo, gitea.CreateIssueOption{
Title: options.Title, Body: options.Body, Ref: options.Ref, Assignees: options.Assignees,
Deadline: options.Deadline, Milestone: options.Milestone, Labels: options.Labels,
})
})
return item, Classify(err, "issue", "", s.Repository)
}
// SetState updates only the selected issue state.
@@ -166,7 +169,8 @@ func (s Issues) SetState(ctx context.Context, number int64, state string) (Issue
}
func (s Issues) edit(ctx context.Context, number int64, opts gitea.EditIssueOption) (Issue, error) {
return repoConvert(ctx, s.Client, s.Repository, discussionFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, discussionFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Issue, *gitea.Response, error) {
return c.Issues.EditIssue(ctx, owner, repo, number, opts)
})
return item, Classify(err, "issue", "#"+strconv.FormatInt(number, 10), s.Repository)
}
-81
View File
@@ -1,81 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package services
import (
"context"
"net/http"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
)
// lookup repeats a read on the instance of the failed request apiErr, with its credential, to
// explain the failure. It returns the response status, or 0 when apiErr was not recorded from a
// request, the read got no response, or a successful response did not decode.
func lookup[T any](ctx context.Context, apiErr *transport.APIError, repository string, op repoOperation[T]) (T, int) {
var zero T
httpClient := apiErr.LookupClient()
if httpClient == nil {
return zero, 0
}
owner, repo, err := splitRepository(repository)
if err != nil {
return zero, 0
}
sdk, err := gitea.NewClient(apiErr.Instance, gitea.SetHTTPClient(httpClient), gitea.SetGiteaVersion(""))
if err != nil {
return zero, 0
}
result, resp, err := op(ctx, sdk, owner, repo)
switch {
case resp == nil || resp.Response == nil:
return zero, 0
case err == nil:
return result, resp.StatusCode
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return zero, 0
}
return zero, resp.StatusCode
}
// RepositoryState holds the repository facts that explain a failed request on it.
type RepositoryState struct {
Empty, Issues, PullRequests, Wiki, Releases, Actions, Projects bool
}
// LookupRepository reads the state of repo for the failed request apiErr; see lookup for the status.
func LookupRepository(ctx context.Context, apiErr *transport.APIError, repo string) (RepositoryState, int) {
item, status := lookup(ctx, apiErr, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
return c.Repositories.GetRepo(ctx, owner, name)
})
if item == nil {
return RepositoryState{}, status
}
return RepositoryState{
Empty: item.Empty, Issues: item.HasIssues, PullRequests: item.HasPullRequests, Wiki: item.HasWiki,
Releases: item.HasReleases, Actions: item.HasActions, Projects: item.HasProjects,
}, status
}
// LookupIsIssue reports whether number names an issue rather than a pull request in repo, for the
// failed request apiErr.
func LookupIsIssue(ctx context.Context, apiErr *transport.APIError, repo string, number int64) bool {
item, status := lookup(ctx, apiErr, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Issue, *gitea.Response, error) {
return c.Issues.GetIssue(ctx, owner, name, number)
})
return status == http.StatusOK && item != nil && item.PullRequest == nil
}
// LookupPullURL returns the web URL of the pull request from head into base in repo, for the failed
// request apiErr, or "".
func LookupPullURL(ctx context.Context, apiErr *transport.APIError, repo, base, head string) string {
item, status := lookup(ctx, apiErr, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullRequest, *gitea.Response, error) {
return c.PullRequests.GetPullRequestByBaseHead(ctx, owner, name, base, head)
})
if status != http.StatusOK || item == nil {
return ""
}
return item.HTMLURL
}
+5 -3
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
gitea "gitea.dev/sdk"
@@ -94,7 +95,7 @@ func (s Organizations) View(ctx context.Context, name string) (Organization, err
return c.Organizations.GetOrg(ctx, name)
})
if err != nil {
return Organization{}, err
return Organization{}, Classify(err, "organization", strconv.Quote(name), "")
}
return organizationFrom(org), nil
}
@@ -159,7 +160,7 @@ func (s Organizations) Create(ctx context.Context, opts OrganizationOptions) (Or
})
})
if err != nil {
return Organization{}, err
return Organization{}, Classify(err, "organization", "", "")
}
return organizationFrom(org), nil
}
@@ -176,9 +177,10 @@ func (s Organizations) Edit(ctx context.Context, name string, opts OrganizationO
// Delete deletes the named organization.
func (s Organizations) Delete(ctx context.Context, name string) error {
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
return c.Organizations.DeleteOrg(ctx, name)
})
return Classify(err, "organization", strconv.Quote(name), "")
}
// OrganizationMember identifies a member without exposing private profile fields.
+8 -5
View File
@@ -237,9 +237,10 @@ func (s Pulls) View(ctx context.Context, n int64) (Pull, error) {
}
func (s Pulls) get(ctx context.Context, n int64) (*gitea.PullRequest, error) {
return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullRequest, *gitea.Response, error) {
item, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullRequest, *gitea.Response, error) {
return c.PullRequests.GetPullRequest(ctx, owner, name, n)
})
return item, Classify(err, "pull request", "#"+strconv.FormatInt(n, 10), s.Repository)
}
// ListOpen returns every open pull request in the repository.
@@ -255,7 +256,7 @@ func (s Pulls) Create(ctx context.Context, opts PullOptions) (Pull, error) {
// TODO(sdk): CreatePullRequestOption lacks allow_maintainer_edit.
var pull gitea.PullRequest
if err := sendJSON(ctx, s.Client, http.MethodPost, "repos/"+s.Repository+"/pulls", opts, &pull); err != nil {
return Pull{}, err
return Pull{}, Classify(err, "pull request", "", s.Repository)
}
return pullFrom(&pull), nil
}
@@ -308,9 +309,10 @@ func (s Pulls) Edit(ctx context.Context, n int64, opts PullEdit) (Pull, error) {
}
return result, nil
}
return repoConvert(ctx, s.Client, s.Repository, pullFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullRequest, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, pullFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullRequest, *gitea.Response, error) {
return c.PullRequests.EditPullRequest(ctx, owner, name, n, editPullOption(opts))
})
return item, Classify(err, "pull request", "#"+strconv.FormatInt(n, 10), s.Repository)
}
func editPullOption(opts PullEdit) gitea.EditPullRequestOption {
@@ -438,8 +440,9 @@ func (s Pulls) Merge(ctx context.Context, n int64, opts MergeOptions) error {
_, resp, err := c.PullRequests.MergePullRequest(ctx, owner, name, n, option)
return resp, err
})
key := "#" + strconv.FormatInt(n, 10)
if refused, ok := errors.AsType[*transport.APIError](err); !ok || refused.Status != http.StatusMethodNotAllowed {
return err
return Classify(err, "pull request", key, s.Repository)
}
var reason string
switch {
@@ -452,7 +455,7 @@ func (s Pulls) Merge(ctx context.Context, n int64, opts MergeOptions) error {
case !pull.Mergeable:
reason = "has conflicting files or is otherwise not mergeable"
default:
return err
return Classify(err, "pull request", key, s.Repository)
}
return fmt.Errorf("pull request #%d %s: %w", n, reason, err)
}
+4 -2
View File
@@ -80,9 +80,10 @@ func (s Releases) ListPage(ctx context.Context, limit, page int) (Page[Release],
// View retrieves a release by its tag, including numeric tags.
func (s Releases) View(ctx context.Context, tag string) (Release, error) {
return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) {
return c.Releases.GetReleaseByTag(ctx, owner, repo, tag)
})
return item, Classify(err, "release", tag, s.Repository)
}
// Latest retrieves the most recent release that is neither a draft nor a prerelease.
@@ -102,12 +103,13 @@ func (s Releases) Archive(ctx context.Context, ref, format string) (io.ReadClose
// Create creates a release and lets Gitea create a missing tag at the supplied target.
func (s Releases) Create(ctx context.Context, opts ReleaseOptions) (Release, error) {
return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) {
return c.Releases.CreateRelease(ctx, owner, repo, gitea.CreateReleaseOption{
TagName: deref(opts.Tag), Target: deref(opts.Target), Title: deref(opts.Title), Note: deref(opts.Body),
IsDraft: deref(opts.Draft), IsPrerelease: deref(opts.Prerelease),
})
})
return item, Classify(err, "release", deref(opts.Tag), s.Repository)
}
// Edit changes only supplied release fields; Gitea ignores empty text fields.
+7 -4
View File
@@ -93,9 +93,10 @@ func (s Repositories) View(ctx context.Context, repo string) (Repository, error)
}
func (s Repositories) get(ctx context.Context, repo string) (*gitea.Repository, error) {
return repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
item, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
return c.Repositories.GetRepo(ctx, owner, name)
})
return item, Classify(err, "repository", repo, "")
}
// readmeNames are the README file names that Gitea shows, in order of preference.
@@ -195,7 +196,7 @@ func (s Repositories) Create(ctx context.Context, org string, opts RepositoryOpt
return c.Repositories.CreateRepo(ctx, options)
})
if err != nil {
return Repository{}, err
return Repository{}, Classify(err, "repository", "", "")
}
return repositoryFrom(item), nil
}
@@ -207,7 +208,7 @@ func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit
style := gitea.MergeStyle(*opts.MergeStyle)
mergeStyle = &style
}
return repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
return c.Repositories.EditRepo(ctx, owner, name, gitea.EditRepoOption{
HasActions: opts.ActionsEnabled, Name: opts.Name, Website: opts.Website, DefaultBranch: opts.DefaultBranch,
Description: opts.Description, Private: opts.Private, Archived: opts.Archived, Template: opts.Template,
@@ -216,6 +217,7 @@ func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit
DefaultDeleteBranchAfterMerge: opts.DeleteBranch,
})
})
return item, Classify(err, "repository", repo, "")
}
// AddTopic adds a topic to the repository.
@@ -234,9 +236,10 @@ func (s Repositories) RemoveTopic(ctx context.Context, repo, topic string) error
// Delete removes the selected repository.
func (s Repositories) Delete(ctx context.Context, repo string) error {
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
err := repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
return c.Repositories.DeleteRepo(ctx, owner, name)
})
return Classify(err, "repository", repo, "")
}
// ForkOptions places a fork: under Organization instead of the account, and named Name instead
+3 -1
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"slices"
"sort"
"strconv"
"strings"
gitea "gitea.dev/sdk"
@@ -135,9 +136,10 @@ func (s Reviews) Create(ctx context.Context, n int64, opts ReviewOptions) (Revie
Path: comment.Path, Body: comment.Body, OldLineNum: comment.OldLine, NewLineNum: comment.NewLine,
})
}
return repoConvert(ctx, s.Client, s.Repository, reviewFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullReview, *gitea.Response, error) {
item, err := repoConvert(ctx, s.Client, s.Repository, reviewFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullReview, *gitea.Response, error) {
return c.PullRequests.CreatePullReview(ctx, owner, name, n, option)
})
return item, Classify(err, "pull request", "#"+strconv.FormatInt(n, 10), s.Repository)
}
// Submit publishes an existing pending review with explicitly collected input.
+5 -3
View File
@@ -6,6 +6,7 @@ package services
import (
"context"
"net/http"
"strconv"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
@@ -45,7 +46,7 @@ func (s SSHKeys) View(ctx context.Context, id int64) (SSHKey, error) {
return c.Users.GetPublicKey(ctx, id)
})
if err != nil {
return SSHKey{}, err
return SSHKey{}, Classify(err, "SSH key", strconv.FormatInt(id, 10), "")
}
return sshKeyFrom(key), nil
}
@@ -56,14 +57,15 @@ func (s SSHKeys) Create(ctx context.Context, title, key string) (SSHKey, error)
return c.Users.CreatePublicKey(ctx, gitea.CreateKeyOption{Title: title, Key: key})
})
if err != nil {
return SSHKey{}, err
return SSHKey{}, Classify(err, "SSH key", "", "")
}
return sshKeyFrom(created), nil
}
// Delete removes only the selected public key.
func (s SSHKeys) Delete(ctx context.Context, id int64) error {
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
return c.Users.DeletePublicKey(ctx, id)
})
return Classify(err, "SSH key", strconv.FormatInt(id, 10), "")
}
+9 -4
View File
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
@@ -121,9 +122,10 @@ func (s Wiki) ListPage(ctx context.Context, limit, page int) (Page[WikiPage], er
// View retrieves and decodes a wiki page.
func (s Wiki) View(ctx context.Context, name string) (WikiPage, error) {
return s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) {
item, err := s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) {
return c.Wiki.GetPage(ctx, owner, repo, name)
})
return item, Classify(err, "wiki page", strconv.Quote(name), s.Repository)
}
func wikiOptions(opts WikiOptions) gitea.CreateWikiPageOptions {
@@ -132,9 +134,10 @@ func wikiOptions(opts WikiOptions) gitea.CreateWikiPageOptions {
// Create creates one wiki page, initializing the wiki if necessary.
func (s Wiki) Create(ctx context.Context, opts WikiOptions) (WikiPage, error) {
return s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) {
item, err := s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) {
return c.Wiki.CreatePage(ctx, owner, repo, wikiOptions(opts))
})
return item, Classify(err, "wiki page", strconv.Quote(opts.Title), s.Repository)
}
// Edit replaces page content and optionally changes its title.
@@ -146,16 +149,18 @@ func (s Wiki) Edit(ctx context.Context, name string, opts WikiOptions) (WikiPage
}
opts.Title = current.Title
}
return s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) {
item, err := s.page(ctx, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.WikiPage, *gitea.Response, error) {
return c.Wiki.EditPage(ctx, owner, repo, name, wikiOptions(opts))
})
return item, Classify(err, "wiki page", strconv.Quote(name), s.Repository)
}
// Delete removes a named wiki page.
func (s Wiki) Delete(ctx context.Context, name string) error {
return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
err := repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
return c.Wiki.DeletePage(ctx, owner, repo, name)
})
return Classify(err, "wiki page", strconv.Quote(name), s.Repository)
}
// Revisions returns one server-paginated page of wiki commits.