Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,15 @@ type Package struct {
Namespace string // @scope for npm, groupId for maven
LatestVersion string // latest version (populated by some registries)
Metadata map[string]any // registry-specific data
Maintainers []Maintainer // populated when the package endpoint includes them
FundingLinks []string // funding or sponsorship URLs
}
```

Some registries (npm, pub, deno, conda) populate `LatestVersion` directly. For others, use `FetchLatestVersionFromPURL`.

`Maintainers` is filled in by npm and hex, whose package endpoints already return maintainer data. Other registries leave it nil; use `FetchMaintainers` for those. `FundingLinks` is filled in from npm `funding`, RubyGems `funding_uri`, Hex links and PyPI `project_urls` whose labels mention funding, sponsoring or donating.

### Version

```go
Expand Down
51 changes: 51 additions & 0 deletions internal/core/funding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package core

import (
"sort"
"strings"
)

// fundingLabelWords are substrings that mark a registry link label as a
// funding page, matched case-insensitively ("Funding", "Sponsor", "Donate").
var fundingLabelWords = []string{"fund", "sponsor", "donat"}

// IsFundingLabel reports whether a link label from registry metadata names
// a funding page, such as PyPI project_urls or Hex package links.
func IsFundingLabel(label string) bool {
label = strings.ToLower(label)
for _, word := range fundingLabelWords {
if strings.Contains(label, word) {
return true
}
}
return false
}

// FundingLinksFromLabels returns the URLs in links whose labels name a
// funding page. The result is sorted and deduplicated so output is stable
// across map iteration order. It returns nil when no labels match.
func FundingLinksFromLabels(links map[string]string) []string {
var funding []string
for label, link := range links {
if IsFundingLabel(label) {
funding = AppendFundingLink(funding, link)
}
}
sort.Strings(funding)
return funding
}

// AppendFundingLink appends link to links after trimming whitespace,
// skipping empty values and links already present.
func AppendFundingLink(links []string, link string) []string {
link = strings.TrimSpace(link)
if link == "" {
return links
}
for _, existing := range links {
if existing == link {
return links
}
}
return append(links, link)
}
86 changes: 86 additions & 0 deletions internal/core/funding_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package core

import (
"reflect"
"testing"
)

func TestIsFundingLabel(t *testing.T) {
tests := []struct {
label string
want bool
}{
{"Funding", true},
{"Sponsor", true},
{"GitHub Sponsors", true},
{"Donate", true},
{"donation", true},
{"Open Collective Fund", true},
{"Homepage", false},
{"Source", false},
{"Changelog", false},
{"", false},
}
for _, tt := range tests {
if got := IsFundingLabel(tt.label); got != tt.want {
t.Errorf("IsFundingLabel(%q) = %v, want %v", tt.label, got, tt.want)
}
}
}

func TestFundingLinksFromLabels(t *testing.T) {
tests := []struct {
name string
links map[string]string
want []string
}{
{
name: "matches funding labels sorted and deduplicated",
links: map[string]string{
"Sponsor": "https://github.com/sponsors/b",
"Funding": "https://opencollective.com/a",
"Donate": " https://github.com/sponsors/b ",
"Homepage": "https://example.com",
},
want: []string{"https://github.com/sponsors/b", "https://opencollective.com/a"},
},
{
name: "skips empty values",
links: map[string]string{"Funding": " "},
want: nil,
},
{
name: "no funding labels",
links: map[string]string{"Source": "https://github.com/a/b"},
want: nil,
},
{
name: "nil map",
links: nil,
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FundingLinksFromLabels(tt.links); !reflect.DeepEqual(got, tt.want) {
t.Errorf("FundingLinksFromLabels() = %#v, want %#v", got, tt.want)
}
})
}
}

func TestAppendFundingLink(t *testing.T) {
var links []string
links = AppendFundingLink(links, "https://a.example")
links = AppendFundingLink(links, "")
links = AppendFundingLink(links, " https://a.example ")
links = AppendFundingLink(links, "https://b.example")

want := []string{"https://a.example", "https://b.example"}
if !reflect.DeepEqual(links, want) {
t.Errorf("AppendFundingLink() = %#v, want %#v", links, want)
}
if got := AppendFundingLink(nil, ""); got != nil {
t.Errorf("AppendFundingLink(nil, \"\") = %#v, want nil", got)
}
}
8 changes: 8 additions & 0 deletions internal/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ type Package struct {
Namespace string // @scope for npm, groupId for maven
LatestVersion string // latest version if returned by registry
Metadata map[string]any // registry-specific data

// Maintainers is populated when the package endpoint already returns
// maintainer data. It is nil otherwise; use FetchMaintainers instead.
Maintainers []Maintainer

// FundingLinks lists funding or sponsorship URLs declared for the
// package. It is nil when the registry exposes none.
FundingLinks []string
}

// Version represents a specific version of a package.
Expand Down
18 changes: 14 additions & 4 deletions internal/hex/hex.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package
}
}

var maintainers []core.Maintainer
if len(resp.Owners) > 0 {
maintainers = convertOwners(resp.Owners)
}

return &core.Package{
Name: resp.Name,
Description: resp.Meta.Description,
Expand All @@ -139,6 +144,8 @@ func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package
"downloads": resp.Downloads.All,
"links": resp.Meta.Links,
},
Maintainers: maintainers,
FundingLinks: core.FundingLinksFromLabels(resp.Meta.Links),
}, nil
}

Expand Down Expand Up @@ -241,16 +248,19 @@ func (r *Registry) FetchMaintainers(ctx context.Context, name string) ([]core.Ma
return nil, err
}

maintainers := make([]core.Maintainer, len(resp.Owners))
for i, owner := range resp.Owners {
return convertOwners(resp.Owners), nil
}

func convertOwners(owners []ownerInfo) []core.Maintainer {
maintainers := make([]core.Maintainer, len(owners))
for i, owner := range owners {
maintainers[i] = core.Maintainer{
UUID: owner.Username,
Login: owner.Username,
Email: owner.Email,
}
}

return maintainers, nil
return maintainers
}

type URLs struct {
Expand Down
74 changes: 74 additions & 0 deletions internal/hex/package_fields_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package hex

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"

"github.com/git-pkgs/registries/internal/core"
)

func TestFetchPackageMaintainersAndFunding(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := packageResponse{
Name: "demo",
Meta: metaInfo{
Links: map[string]string{
"GitHub": "https://github.com/demo/demo",
"Sponsor": "https://github.com/sponsors/demo",
},
},
Owners: []ownerInfo{
{Username: "alice", Email: "alice@example.com"},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()

reg := New(server.URL, core.DefaultClient())
pkg, err := reg.FetchPackage(context.Background(), "demo")
if err != nil {
t.Fatalf("FetchPackage failed: %v", err)
}

wantMaintainers := []core.Maintainer{
{UUID: "alice", Login: "alice", Email: "alice@example.com"},
}
if !reflect.DeepEqual(pkg.Maintainers, wantMaintainers) {
t.Errorf("Maintainers = %#v, want %#v", pkg.Maintainers, wantMaintainers)
}

wantFunding := []string{"https://github.com/sponsors/demo"}
if !reflect.DeepEqual(pkg.FundingLinks, wantFunding) {
t.Errorf("FundingLinks = %#v, want %#v", pkg.FundingLinks, wantFunding)
}
}

func TestFetchPackageWithoutMaintainersOrFunding(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := packageResponse{
Name: "bare",
Meta: metaInfo{Links: map[string]string{"GitHub": "https://github.com/bare/bare"}},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()

reg := New(server.URL, core.DefaultClient())
pkg, err := reg.FetchPackage(context.Background(), "bare")
if err != nil {
t.Fatalf("FetchPackage failed: %v", err)
}
if pkg.Maintainers != nil {
t.Errorf("Maintainers = %#v, want nil", pkg.Maintainers)
}
if pkg.FundingLinks != nil {
t.Errorf("FundingLinks = %#v, want nil", pkg.FundingLinks)
}
}
35 changes: 32 additions & 3 deletions internal/npm/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package
"dist-tags": resp.DistTags,
"funding": latest.Funding,
},
FundingLinks: extractFundingLinks(latest.Funding),
}
if len(resp.Maintainers) > 0 {
pkg.Maintainers = convertMaintainers(resp.Maintainers)
}

return pkg, nil
Expand Down Expand Up @@ -354,16 +358,41 @@ func (r *Registry) FetchMaintainers(ctx context.Context, name string) ([]core.Ma
return nil, err
}

maintainers := make([]core.Maintainer, len(resp.Maintainers))
for i, m := range resp.Maintainers {
return convertMaintainers(resp.Maintainers), nil
}

func convertMaintainers(infos []maintainerInfo) []core.Maintainer {
maintainers := make([]core.Maintainer, len(infos))
for i, m := range infos {
maintainers[i] = core.Maintainer{
UUID: m.Name,
Login: m.Name,
Email: m.Email,
}
}
return maintainers
}

return maintainers, nil
// extractFundingLinks normalizes the package.json "funding" field, which may
// be a URL string, an object with a "url" key, or an array mixing both.
// Declaration order is kept since npm treats the first entry as primary.
func extractFundingLinks(v interface{}) []string {
items, ok := v.([]interface{})
if !ok {
items = []interface{}{v}
}
var links []string
for _, item := range items {
switch f := item.(type) {
case string:
links = core.AppendFundingLink(links, f)
case map[string]interface{}:
if u, ok := f["url"].(string); ok {
links = core.AppendFundingLink(links, u)
}
}
}
return links
}

func extractString(v interface{}) string {
Expand Down
Loading
Loading