- errorlint: fmt.Errorf %v→%w across all handlers (errors now wrap the underlying SDK/HTTP error for errors.Is/As); errors.Is for context.Canceled in cmd - staticcheck quickfixes: Fprintf over WriteString(Sprintf), tagged switches, omit inferred 'any'; QF1001 resolved by hoisting hex checks into isHex predicates (normalizeColor, validateSHA) - govet: reflect.Ptr → reflect.Pointer in pkg/ptr - errcheck: explicit '_ =' for fs.Parse (ExitOnError) and deferred zap Sync - repair four test assertions the autofix broke (it dropped the 're.Code != X' half of combined type-assert checks, inverting the test); all errors.As sites use 'var re *resource.ResourceError' golangci-lint: 0 issues. staticcheck, vet, tests: clean.
389 lines
13 KiB
Go
389 lines
13 KiB
Go
package resource
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// ErrInvalidParams indicates a URI parse failure that should map to JSON-RPC -32602.
|
|
var ErrInvalidParams = errors.New("invalid params")
|
|
|
|
// OwnerParams holds parsed fields from forgejo://owner/{owner}.
|
|
type OwnerParams struct {
|
|
Owner string
|
|
}
|
|
|
|
// RepoParams holds parsed fields from forgejo://repo/{owner}/{repo}.
|
|
type RepoParams struct {
|
|
Owner string
|
|
Repo string
|
|
}
|
|
|
|
// CommitParams holds parsed fields from forgejo://repo/{owner}/{repo}/commit/{sha}.
|
|
// SHA must be exactly 40 hex characters.
|
|
type CommitParams struct {
|
|
Owner string
|
|
Repo string
|
|
SHA string
|
|
}
|
|
|
|
// IssueParams holds parsed fields from forgejo://repo/{owner}/{repo}/issue/{index}.
|
|
type IssueParams struct {
|
|
Owner string
|
|
Repo string
|
|
Index int64
|
|
}
|
|
|
|
// PRParams holds parsed fields from forgejo://repo/{owner}/{repo}/pr/{index}.
|
|
type PRParams struct {
|
|
Owner string
|
|
Repo string
|
|
Index int64
|
|
}
|
|
|
|
// CommentParams holds parsed fields from forgejo://repo/{owner}/{repo}/{kind}/{index}/comment/{id}.
|
|
// Kind is constrained to "issue" or "pr".
|
|
type CommentParams struct {
|
|
Owner string
|
|
Repo string
|
|
Kind string
|
|
Index int64
|
|
ID int64
|
|
}
|
|
|
|
// StatusParams holds parsed fields from forgejo://repo/{owner}/{repo}/commit/{sha}/status.
|
|
type StatusParams struct {
|
|
Owner string
|
|
Repo string
|
|
SHA string
|
|
}
|
|
|
|
// ParseOwner parses forgejo://owner/{owner}.
|
|
func ParseOwner(uri string) (OwnerParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return OwnerParams{}, err
|
|
}
|
|
// host = "owner", path = "/{owner}"
|
|
if u.Host != "owner" {
|
|
return OwnerParams{}, fmt.Errorf("%w: expected forgejo://owner/{owner}, got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
if len(parts) != 1 || parts[0] == "" {
|
|
return OwnerParams{}, fmt.Errorf("%w: expected forgejo://owner/{owner}, got %q", ErrInvalidParams, uri)
|
|
}
|
|
return OwnerParams{Owner: parts[0]}, nil
|
|
}
|
|
|
|
// ParseRepo parses forgejo://repo/{owner}/{repo}.
|
|
func ParseRepo(uri string) (RepoParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return RepoParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return RepoParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
if len(parts) != 2 {
|
|
return RepoParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}, got %q", ErrInvalidParams, uri)
|
|
}
|
|
return RepoParams{Owner: parts[0], Repo: parts[1]}, nil
|
|
}
|
|
|
|
// ParseCommit parses forgejo://repo/{owner}/{repo}/commit/{sha}.
|
|
// Returns an error if sha is not exactly 40 hex characters.
|
|
func ParseCommit(uri string) (CommitParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return CommitParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return CommitParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [owner, repo, "commit", sha]
|
|
if len(parts) != 4 || parts[2] != "commit" {
|
|
return CommitParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}/commit/{sha}, got %q", ErrInvalidParams, uri)
|
|
}
|
|
sha := parts[3]
|
|
if err := validateSHA(sha); err != nil {
|
|
return CommitParams{}, fmt.Errorf("%w: invalid URI %q: %w", ErrInvalidParams, uri, err)
|
|
}
|
|
return CommitParams{Owner: parts[0], Repo: parts[1], SHA: sha}, nil
|
|
}
|
|
|
|
// ParseIssue parses forgejo://repo/{owner}/{repo}/issue/{index}.
|
|
func ParseIssue(uri string) (IssueParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return IssueParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return IssueParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [owner, repo, "issue", index]
|
|
if len(parts) != 4 || parts[2] != "issue" {
|
|
return IssueParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}/issue/{index}, got %q", ErrInvalidParams, uri)
|
|
}
|
|
index, err := strconv.ParseInt(parts[3], 10, 64)
|
|
if err != nil {
|
|
return IssueParams{}, fmt.Errorf("%w: invalid URI %q: index must be numeric", ErrInvalidParams, uri)
|
|
}
|
|
return IssueParams{Owner: parts[0], Repo: parts[1], Index: index}, nil
|
|
}
|
|
|
|
// ParsePR parses forgejo://repo/{owner}/{repo}/pr/{index}.
|
|
func ParsePR(uri string) (PRParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return PRParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return PRParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [owner, repo, "pr", index]
|
|
if len(parts) != 4 || parts[2] != "pr" {
|
|
return PRParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}/pr/{index}, got %q", ErrInvalidParams, uri)
|
|
}
|
|
index, err := strconv.ParseInt(parts[3], 10, 64)
|
|
if err != nil {
|
|
return PRParams{}, fmt.Errorf("%w: invalid URI %q: index must be numeric", ErrInvalidParams, uri)
|
|
}
|
|
return PRParams{Owner: parts[0], Repo: parts[1], Index: index}, nil
|
|
}
|
|
|
|
// ParseComment parses forgejo://repo/{owner}/{repo}/{kind}/{index}/comment/{id}.
|
|
// Kind must be "issue" or "pr".
|
|
func ParseComment(uri string) (CommentParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return CommentParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return CommentParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [owner, repo, kind, index, "comment", id]
|
|
if len(parts) != 6 || parts[4] != "comment" {
|
|
return CommentParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}/{kind}/{index}/comment/{id}, got %q", ErrInvalidParams, uri)
|
|
}
|
|
kind := parts[2]
|
|
if kind != "issue" && kind != "pr" {
|
|
return CommentParams{}, fmt.Errorf("%w: invalid URI %q: kind must be 'issue' or 'pr', got %q", ErrInvalidParams, uri, kind)
|
|
}
|
|
index, err := strconv.ParseInt(parts[3], 10, 64)
|
|
if err != nil {
|
|
return CommentParams{}, fmt.Errorf("%w: invalid URI %q: index must be numeric", ErrInvalidParams, uri)
|
|
}
|
|
id, err := strconv.ParseInt(parts[5], 10, 64)
|
|
if err != nil {
|
|
return CommentParams{}, fmt.Errorf("%w: invalid URI %q: id must be numeric", ErrInvalidParams, uri)
|
|
}
|
|
return CommentParams{Owner: parts[0], Repo: parts[1], Kind: kind, Index: index, ID: id}, nil
|
|
}
|
|
|
|
// ParseStatus parses forgejo://repo/{owner}/{repo}/commit/{sha}/status.
|
|
func ParseStatus(uri string) (StatusParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return StatusParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return StatusParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [owner, repo, "commit", sha, "status"]
|
|
if len(parts) != 5 || parts[2] != "commit" || parts[4] != "status" {
|
|
return StatusParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}/commit/{sha}/status, got %q", ErrInvalidParams, uri)
|
|
}
|
|
sha := parts[3]
|
|
if err := validateSHA(sha); err != nil {
|
|
return StatusParams{}, fmt.Errorf("%w: invalid URI %q: %w", ErrInvalidParams, uri, err)
|
|
}
|
|
return StatusParams{Owner: parts[0], Repo: parts[1], SHA: sha}, nil
|
|
}
|
|
|
|
// BranchProtectionsParams holds parsed fields from
|
|
// forgejo://repo/{owner}/{repo}/branch_protections (collection).
|
|
type BranchProtectionsParams struct {
|
|
Owner string
|
|
Repo string
|
|
}
|
|
|
|
// BranchProtectionParams holds parsed fields from
|
|
// forgejo://repo/{owner}/{repo}/branch_protection/{rule} (single entity).
|
|
// Rule is the rule name and MAY contain slashes (branch-name globs such as
|
|
// "release/*"), so it is reassembled from all trailing path segments.
|
|
type BranchProtectionParams struct {
|
|
Owner string
|
|
Repo string
|
|
Rule string
|
|
}
|
|
|
|
// ParseBranchProtections parses forgejo://repo/{owner}/{repo}/branch_protections.
|
|
func ParseBranchProtections(uri string) (BranchProtectionsParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return BranchProtectionsParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return BranchProtectionsParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [owner, repo, "branch_protections"]
|
|
if len(parts) != 3 || parts[2] != "branch_protections" {
|
|
return BranchProtectionsParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}/branch_protections, got %q", ErrInvalidParams, uri)
|
|
}
|
|
return BranchProtectionsParams{Owner: parts[0], Repo: parts[1]}, nil
|
|
}
|
|
|
|
// ParseBranchProtection parses forgejo://repo/{owner}/{repo}/branch_protection/{rule}.
|
|
// The rule name is the remainder after "branch_protection/" so glob rules
|
|
// containing slashes round-trip.
|
|
func ParseBranchProtection(uri string) (BranchProtectionParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return BranchProtectionParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return BranchProtectionParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [owner, repo, "branch_protection", rule...]
|
|
if len(parts) < 4 || parts[2] != "branch_protection" {
|
|
return BranchProtectionParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}/branch_protection/{rule}, got %q", ErrInvalidParams, uri)
|
|
}
|
|
rule := strings.Join(parts[3:], "/")
|
|
if rule == "" {
|
|
return BranchProtectionParams{}, fmt.Errorf("%w: empty rule name in %q", ErrInvalidParams, uri)
|
|
}
|
|
return BranchProtectionParams{Owner: parts[0], Repo: parts[1], Rule: rule}, nil
|
|
}
|
|
|
|
// LabelParams holds parsed fields from forgejo://repo/{owner}/{repo}/label/{id}.
|
|
type LabelParams struct {
|
|
Owner string
|
|
Repo string
|
|
ID int64
|
|
}
|
|
|
|
// LabelsParams holds parsed fields from forgejo://repo/{owner}/{repo}/labels.
|
|
type LabelsParams struct {
|
|
Owner string
|
|
Repo string
|
|
}
|
|
|
|
// OrgLabelsParams holds parsed fields from forgejo://org/{org}/labels.
|
|
type OrgLabelsParams struct {
|
|
Org string
|
|
}
|
|
|
|
// ParseLabel parses forgejo://repo/{owner}/{repo}/label/{id}.
|
|
// Returns ErrInvalidParams if the id is not numeric.
|
|
func ParseLabel(uri string) (LabelParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return LabelParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return LabelParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [owner, repo, "label", id]
|
|
if len(parts) != 4 || parts[2] != "label" {
|
|
return LabelParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}/label/{id}, got %q", ErrInvalidParams, uri)
|
|
}
|
|
id, err := strconv.ParseInt(parts[3], 10, 64)
|
|
if err != nil {
|
|
return LabelParams{}, fmt.Errorf("%w: invalid URI %q: id must be numeric", ErrInvalidParams, uri)
|
|
}
|
|
return LabelParams{Owner: parts[0], Repo: parts[1], ID: id}, nil
|
|
}
|
|
|
|
// ParseLabels parses forgejo://repo/{owner}/{repo}/labels.
|
|
func ParseLabels(uri string) (LabelsParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return LabelsParams{}, err
|
|
}
|
|
if u.Host != "repo" {
|
|
return LabelsParams{}, fmt.Errorf("%w: expected forgejo://repo/..., got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [owner, repo, "labels"]
|
|
if len(parts) != 3 || parts[2] != "labels" {
|
|
return LabelsParams{}, fmt.Errorf("%w: expected forgejo://repo/{owner}/{repo}/labels, got %q", ErrInvalidParams, uri)
|
|
}
|
|
return LabelsParams{Owner: parts[0], Repo: parts[1]}, nil
|
|
}
|
|
|
|
// ParseOrgLabels parses forgejo://org/{org}/labels.
|
|
func ParseOrgLabels(uri string) (OrgLabelsParams, error) {
|
|
u, err := parseForgejoURI(uri)
|
|
if err != nil {
|
|
return OrgLabelsParams{}, err
|
|
}
|
|
if u.Host != "org" {
|
|
return OrgLabelsParams{}, fmt.Errorf("%w: expected forgejo://org/{org}/labels, got %q", ErrInvalidParams, uri)
|
|
}
|
|
parts := splitPath(u.Path)
|
|
// parts: [org, "labels"]
|
|
if len(parts) != 2 || parts[1] != "labels" {
|
|
return OrgLabelsParams{}, fmt.Errorf("%w: expected forgejo://org/{org}/labels, got %q", ErrInvalidParams, uri)
|
|
}
|
|
return OrgLabelsParams{Org: parts[0]}, nil
|
|
}
|
|
|
|
func parseForgejoURI(uri string) (*url.URL, error) {
|
|
u, err := url.Parse(uri)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: malformed URI %q: %w", ErrInvalidParams, uri, err)
|
|
}
|
|
if u.Scheme != "forgejo" {
|
|
return nil, fmt.Errorf("%w: invalid URI scheme: expected 'forgejo', got %q", ErrInvalidParams, u.Scheme)
|
|
}
|
|
// Reject empty or whitespace-only path segments so that
|
|
// forgejo://repo/foo//bar and forgejo://repo/foo/bar/ do not silently
|
|
// alias forgejo://repo/foo/bar. Distinct URIs must mean distinct
|
|
// resources for content-addressable caching to be correct.
|
|
path := strings.TrimPrefix(u.Path, "/")
|
|
for _, seg := range strings.Split(path, "/") {
|
|
if strings.TrimSpace(seg) == "" {
|
|
return nil, fmt.Errorf("%w: invalid URI %q: empty or whitespace-only path segment", ErrInvalidParams, uri)
|
|
}
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// splitPath splits a URI path into non-empty segments.
|
|
func splitPath(path string) []string {
|
|
parts := strings.Split(strings.TrimPrefix(path, "/"), "/")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// validateSHA returns an error if sha is not exactly 40 hex characters
|
|
// (either case).
|
|
func validateSHA(sha string) error {
|
|
if len(sha) != 40 {
|
|
return fmt.Errorf("%w: sha must be exactly 40 hex characters, got %d", ErrInvalidParams, len(sha))
|
|
}
|
|
for _, c := range sha {
|
|
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
|
if !isHex {
|
|
return fmt.Errorf("%w: sha contains invalid character %q", ErrInvalidParams, c)
|
|
}
|
|
}
|
|
return nil
|
|
}
|