From 1fb1dcd8a110e492a8b849c32b52ef989008d092 Mon Sep 17 00:00:00 2001 From: Paolo Valletta Date: Fri, 30 Oct 2026 09:28:34 +0100 Subject: [PATCH 1/3] Add a funding subpackage that reads FUNDING.yml Dependency funding tools need the donation links for a repository they have already resolved. GitHub keeps them in FUNDING.yml, and looks for it in .github/, the root, docs/ and then the owner's .github repository; this package does the same walk through FileService and returns the platform handles and custom URLs. Parse is callable on supplied content, so a consumer that already has the file does not need a FileService. The parser covers the flat subset GitHub accepts (scalars, quoted values, inline and block lists, comments) and ignores keys it does not know, so a file using a platform added later still parses. Closes #174 --- README.md | 13 ++ funding/funding.go | 296 +++++++++++++++++++++++++++++++++++++ funding/funding_test.go | 317 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 626 insertions(+) create mode 100644 funding/funding.go create mode 100644 funding/funding_test.go diff --git a/README.md b/README.md index 9c686f1..0824224 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,19 @@ sha, err = client.ResolveCommit(ctx, "https://github.com/actions/checkout", "v4. A ref that does not exist returns `forges.ErrNotFound`. A ref that is already a full 40-character SHA is normalized and returned without a network request. +Funding links live in a `FUNDING.yml` file, and the `funding` package looks for it where GitHub does: `.github/`, the repository root, `docs/`, then the owner's `.github` repository. + +```go +import "github.com/git-pkgs/forge/funding" + +f, _ := client.ForgeFor("github.com") +links, err := funding.Discover(ctx, f.Files(), "octocat", "hello-world", "") +// links.GitHub holds ["sponsors-handle"], links.Custom the URLs the file lists. + +// A caller that already has the file parses it without a forge: +links, err = funding.Parse(body) +``` + The GitHub backend also exposes a standalone resolver for callers that do not need the full `Forge` interface: ```go diff --git a/funding/funding.go b/funding/funding.go new file mode 100644 index 0000000..9d1a729 --- /dev/null +++ b/funding/funding.go @@ -0,0 +1,296 @@ +// Package funding discovers the funding links a repository advertises, the same +// way GitHub does for the Sponsor button: a FUNDING.yml file, looked up in the +// places GitHub looks. +package funding + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "strings" + + forges "github.com/git-pkgs/forge" +) + +// Links is the parsed content of a FUNDING.yml file. +// +// The field names follow the file's keys, not GitHub's button labels. A file +// may declare several destinations, and each one is a handle the platform +// understands, except Custom, which holds URLs. +type Links struct { + GitHub []string `json:"github,omitempty"` + Patreon string `json:"patreon,omitempty"` + OpenCollective string `json:"open_collective,omitempty"` + KoFi string `json:"ko_fi,omitempty"` + Tidelift string `json:"tidelift,omitempty"` + CommunityBridge string `json:"community_bridge,omitempty"` + Liberapay string `json:"liberapay,omitempty"` + IssueHunt string `json:"issuehunt,omitempty"` + LFXCrowdfunding string `json:"lfx_crowdfunding,omitempty"` + Polar string `json:"polar,omitempty"` + BuyMeACoffee string `json:"buy_me_a_coffee,omitempty"` + ThanksDev string `json:"thanks_dev,omitempty"` + Otechie string `json:"otechie,omitempty"` + Custom []string `json:"custom,omitempty"` +} + +// Empty reports whether the file declared no funding destination. +func (l Links) Empty() bool { + return len(l.GitHub) == 0 && len(l.Custom) == 0 && + l.Patreon == "" && l.OpenCollective == "" && l.KoFi == "" && + l.Tidelift == "" && l.CommunityBridge == "" && l.Liberapay == "" && + l.IssueHunt == "" && l.LFXCrowdfunding == "" && l.Polar == "" && + l.BuyMeACoffee == "" && l.ThanksDev == "" && l.Otechie == "" +} + +// URL returns the funding page GitHub links for a handle, or an empty string +// for Custom: those entries are already URLs and are returned unchanged. +func (l Links) URL(platform, handle string) string { + switch platform { + case "github": + return "https://github.com/sponsors/" + handle + case "patreon": + return "https://www.patreon.com/" + handle + case "open_collective": + return "https://opencollective.com/" + handle + case "ko_fi": + return "https://ko-fi.com/" + handle + case "tidelift": + return "https://tidelift.com/funding/github/" + handle + case "community_bridge": + return "https://funding.communitybridge.org/projects/" + handle + case "liberapay": + return "https://liberapay.com/" + handle + case "issuehunt": + return "https://issuehunt.io/r/" + handle + case "lfx_crowdfunding": + return "https://crowdfunding.lfx.linuxfoundation.org/projects/" + handle + case "polar": + return "https://polar.sh/" + handle + case "buy_me_a_coffee": + return "https://www.buymeacoffee.com/" + handle + case "thanks_dev": + return "https://thanks.dev/" + handle + case "otechie": + return "https://otechie.com/" + handle + } + return "" +} + +// locations is where GitHub looks for a funding file, in order: the .github +// directory, the repository root, docs/, and finally the owner's .github +// repository. Discovery stops at the first file it finds, even when that file +// declares nothing: existence, not content, decides which file GitHub reads. +var locations = []Location{ + {Path: ".github/FUNDING.yml"}, + {Path: "FUNDING.yml"}, + {Path: "docs/FUNDING.yml"}, + {Repo: ".github", Path: ".github/FUNDING.yml"}, + {Repo: ".github", Path: "FUNDING.yml"}, +} + +// Location is one file a funding file may live in. An empty Repo means the +// repository being asked about; a set one names another repository of the same +// owner, which is how the owner's .github repository is described. +type Location struct { + Repo string + Path string +} + +// Discover reads the funding file GitHub would read for owner/repo. ref is the +// branch or commit to read from; empty means the repository's default branch. +// The owner's .github repository is always read from its own default branch, +// because a tag or branch name from owner/repo does not exist there. +// +// A file that is not there is not an error: Discover moves on to the next +// location and returns empty Links when none of them has one. A forge that +// cannot read files at all (ErrNotSupported, which the Tangled and Bitbucket +// backends return for every Get) is treated the same way. Any other error — an +// authentication failure, a rate limit — is returned, wrapped, because it says +// nothing about whether the repository is funded; errors.Is and errors.As see +// through the wrap. +func Discover(ctx context.Context, files forges.FileService, owner, repo, ref string) (Links, error) { + if files == nil { + return Links{}, errors.New("funding: nil file service") + } + for _, loc := range locations { + fileRepo, probeRef := repo, ref + if loc.Repo != "" { + fileRepo, probeRef = loc.Repo, "" + } + content, err := files.Get(ctx, owner, fileRepo, loc.Path, probeRef) + if err != nil { + if isMissing(err) { + continue + } + return Links{}, fmt.Errorf("funding: read %s/%s %s: %w", owner, fileRepo, loc.Path, err) + } + if content == nil { + continue + } + links, err := Parse(content.Content) + if err != nil { + return Links{}, fmt.Errorf("funding: parse %s/%s %s: %w", owner, fileRepo, loc.Path, err) + } + return links, nil + } + return Links{}, nil +} + +// isMissing reports whether an error means there is nothing to read: the file +// is not there, or the forge has no file API at all. +func isMissing(err error) bool { + if errors.Is(err, forges.ErrNotFound) || errors.Is(err, forges.ErrNotSupported) { + return true + } + var httpErr *forges.HTTPError + return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound +} + +// scalarKeys are the FUNDING.yml keys that hold a single handle. +var scalarKeys = map[string]func(*Links) *string{ + "patreon": func(l *Links) *string { return &l.Patreon }, + "open_collective": func(l *Links) *string { return &l.OpenCollective }, + "ko_fi": func(l *Links) *string { return &l.KoFi }, + "tidelift": func(l *Links) *string { return &l.Tidelift }, + "community_bridge": func(l *Links) *string { return &l.CommunityBridge }, + "liberapay": func(l *Links) *string { return &l.Liberapay }, + "issuehunt": func(l *Links) *string { return &l.IssueHunt }, + "lfx_crowdfunding": func(l *Links) *string { return &l.LFXCrowdfunding }, + "polar": func(l *Links) *string { return &l.Polar }, + "buy_me_a_coffee": func(l *Links) *string { return &l.BuyMeACoffee }, + "thanks_dev": func(l *Links) *string { return &l.ThanksDev }, + "otechie": func(l *Links) *string { return &l.Otechie }, +} + +// listKeys are the FUNDING.yml keys that hold a list of handles or URLs. +var listKeys = map[string]func(*Links) *[]string{ + "github": func(l *Links) *[]string { return &l.GitHub }, + "custom": func(l *Links) *[]string { return &l.Custom }, +} + +// Parse reads the body of a FUNDING.yml file. +// +// The format is the small flat subset GitHub accepts: one "key: value" per line, +// a value in quotes, a bare handle, an inline list "[a, b]", or a list of "- x" +// lines below the key. Comments start at "#", a "---" document marker is +// ignored, and a leading UTF-8 BOM is stripped. Unknown keys are ignored, so a +// file that uses a platform added after this package was written still parses, +// and a line that is none of those shapes is an error rather than a silent skip. +// +// Two shapes are outside this subset rather than mis-parsed into something else: +// a "#" preceded by a space inside a quoted value, and a comma inside a quoted +// inline-list item. No FUNDING.yml seen in the wild uses either. +func Parse(content []byte) (Links, error) { + var links Links + var pending *[]string // the list a "key:" line without a value opens + + content = bytes.TrimPrefix(content, []byte("\ufeff")) + + flush := func() { + pending = nil + } + + for number, raw := range strings.Split(string(content), "\n") { + line := strings.TrimRight(raw, " \t\r") + line = stripComment(line) + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" || trimmed == "---" || trimmed == "..." { + continue + } + + if strings.HasPrefix(trimmed, "- ") || trimmed == "-" { + if pending == nil { + return Links{}, fmt.Errorf("line %d: list item outside a list key", number+1) + } + item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) + if item == "" { + return Links{}, fmt.Errorf("line %d: empty list item", number+1) + } + *pending = append(*pending, unquote(item)) + continue + } + + key, value, ok := strings.Cut(trimmed, ":") + if !ok { + return Links{}, fmt.Errorf("line %d: want \"key: value\", got %q", number+1, trimmed) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + flush() + + if setter, known := scalarKeys[key]; known { + if value == "" { + continue + } + *setter(&links) = unquote(value) + continue + } + if setter, known := listKeys[key]; known { + list := setter(&links) + switch { + case value == "": + pending = list + case strings.HasPrefix(value, "["): + items, err := parseInlineList(value) + if err != nil { + return Links{}, fmt.Errorf("line %d: %w", number+1, err) + } + *list = append(*list, items...) + default: + *list = append(*list, unquote(value)) + } + continue + } + // An unknown key: a platform this package does not know about yet. + } + return links, nil +} + +// stripComment removes a "#" comment. A "#" only starts a comment at the start +// of the line or after whitespace, so a URL fragment survives. +func stripComment(line string) string { + for i := 0; i < len(line); i++ { + if line[i] != '#' { + continue + } + if i == 0 || line[i-1] == ' ' || line[i-1] == '\t' { + return line[:i] + } + } + return line +} + +// parseInlineList reads "[a, b, 'c']". +func parseInlineList(value string) ([]string, error) { + if !strings.HasSuffix(value, "]") { + return nil, fmt.Errorf("unterminated list %q", value) + } + inner := strings.TrimSpace(value[1 : len(value)-1]) + if inner == "" { + return nil, nil + } + parts := strings.Split(inner, ",") + items := make([]string, 0, len(parts)) + for _, part := range parts { + item := unquote(strings.TrimSpace(part)) + if item == "" { + return nil, fmt.Errorf("empty item in list %q", value) + } + items = append(items, item) + } + return items, nil +} + +// unquote removes one pair of matching quotes. +func unquote(value string) string { + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + return value[1 : len(value)-1] + } + } + return value +} diff --git a/funding/funding_test.go b/funding/funding_test.go new file mode 100644 index 0000000..34f2c38 --- /dev/null +++ b/funding/funding_test.go @@ -0,0 +1,317 @@ +package funding + +import ( + "context" + "net/http" + "reflect" + "strings" + "testing" + + forges "github.com/git-pkgs/forge" +) + +// fakeFiles answers FileService.Get from a map keyed by "owner/repo/path@ref", +// so a test can say exactly which funding file exists where. +type fakeFiles struct { + files map[string]string + errs map[string]error + calls []string +} + +func (f *fakeFiles) key(owner, repo, path, ref string) string { + return owner + "/" + repo + "/" + path + "@" + ref +} + +func (f *fakeFiles) Get(_ context.Context, owner, repo, path, ref string) (*forges.FileContent, error) { + key := f.key(owner, repo, path, ref) + f.calls = append(f.calls, key) + if err, ok := f.errs[key]; ok { + return nil, err + } + body, ok := f.files[key] + if !ok { + return nil, forges.ErrNotFound + } + return &forges.FileContent{Path: path, Content: []byte(body)}, nil +} + +func (f *fakeFiles) List(_ context.Context, _, _, _, _ string) ([]forges.FileEntry, error) { + return nil, forges.ErrNotSupported +} + +func TestParseReadsEveryPlatform(t *testing.T) { + content := `# Funding for this project +github: [alice, bob] +patreon: alice +open_collective: "my-collective" +ko_fi: 'alice' +tidelift: npm/left-pad +community_bridge: my-project +liberapay: alice +issuehunt: alice +lfx_crowdfunding: my-project +polar: alice +buy_me_a_coffee: alice +thanks_dev: gh/alice +custom: ["https://example.com/donate", "https://example.org/#giving"] +otechie: alice +unknown_platform: ignored +` + links, err := Parse([]byte(content)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + want := Links{ + GitHub: []string{"alice", "bob"}, + Patreon: "alice", + OpenCollective: "my-collective", + KoFi: "alice", + Tidelift: "npm/left-pad", + CommunityBridge: "my-project", + Liberapay: "alice", + IssueHunt: "alice", + LFXCrowdfunding: "my-project", + Polar: "alice", + BuyMeACoffee: "alice", + ThanksDev: "gh/alice", + Otechie: "alice", + Custom: []string{"https://example.com/donate", "https://example.org/#giving"}, + } + if !reflect.DeepEqual(links, want) { + t.Errorf("Parse:\n got %+v\nwant %+v", links, want) + } + if links.Empty() { + t.Error("Empty() = true for a file that declares funding") + } +} + +func TestParseReadsBlockListsAndScalars(t *testing.T) { + content := `github: + - alice + - bob +custom: + - https://example.com/donate + - https://example.org/docs#donate +patreon: alice # a trailing comment +` + links, err := Parse([]byte(content)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !reflect.DeepEqual(links.GitHub, []string{"alice", "bob"}) { + t.Errorf("GitHub = %v", links.GitHub) + } + // A "#" inside a URL is not a comment, so the fragment survives. + if !reflect.DeepEqual(links.Custom, []string{"https://example.com/donate", "https://example.org/docs#donate"}) { + t.Errorf("Custom = %v", links.Custom) + } + if links.Patreon != "alice" { + t.Errorf("Patreon = %q", links.Patreon) + } +} + +func TestParseErrorsOnMalformedLines(t *testing.T) { + for name, content := range map[string]string{ + "list item without a key": "- alice\n", + "line without a colon": "github\n", + "unterminated list": "github: [alice, bob\n", + "empty list item": "github: [alice, , bob]\n", + } { + t.Run(name, func(t *testing.T) { + if _, err := Parse([]byte(content)); err == nil { + t.Errorf("Parse(%q) returned no error", content) + } + }) + } +} + +func TestParseSkipsABOMAndADocumentMarker(t *testing.T) { + content := "\ufeff---\ngithub: alice\n...\n" + links, err := Parse([]byte(content)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !reflect.DeepEqual(links.GitHub, []string{"alice"}) { + t.Errorf("GitHub = %v, want [alice]", links.GitHub) + } +} + +func TestParseHandlesCRLF(t *testing.T) { + content := "github: [alice, bob]\r\npatreon: alice\r\n" + links, err := Parse([]byte(content)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !reflect.DeepEqual(links.GitHub, []string{"alice", "bob"}) || links.Patreon != "alice" { + t.Errorf("Parse = %+v", links) + } +} + +func TestParseEmptyContentIsEmpty(t *testing.T) { + links, err := Parse(nil) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !links.Empty() { + t.Errorf("Parse(nil) = %+v, want empty", links) + } +} + +func TestURL(t *testing.T) { + links := Links{} + for platform, want := range map[string]string{ + "github": "https://github.com/sponsors/alice", + "patreon": "https://www.patreon.com/alice", + "open_collective": "https://opencollective.com/alice", + "ko_fi": "https://ko-fi.com/alice", + "tidelift": "https://tidelift.com/funding/github/alice", + "community_bridge": "https://funding.communitybridge.org/projects/alice", + "liberapay": "https://liberapay.com/alice", + "issuehunt": "https://issuehunt.io/r/alice", + "lfx_crowdfunding": "https://crowdfunding.lfx.linuxfoundation.org/projects/alice", + "polar": "https://polar.sh/alice", + "buy_me_a_coffee": "https://www.buymeacoffee.com/alice", + "thanks_dev": "https://thanks.dev/alice", + } { + if got := links.URL(platform, "alice"); got != want { + t.Errorf("URL(%q) = %q, want %q", platform, got, want) + } + } + // Custom holds URLs already, so there is nothing to build. + if got := links.URL("custom", "https://example.com"); got != "" { + t.Errorf("URL(custom) = %q, want empty", got) + } +} + +func TestDiscoverPrefersTheGitHubDirectory(t *testing.T) { + files := &fakeFiles{files: map[string]string{ + "alice/project/.github/FUNDING.yml@main": "github: alice\n", + "alice/project/FUNDING.yml@main": "patreon: alice\n", + }} + links, err := Discover(context.Background(), files, "alice", "project", "main") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if !reflect.DeepEqual(links.GitHub, []string{"alice"}) || links.Patreon != "" { + t.Errorf("Discover = %+v, want the .github directory file", links) + } + if len(files.calls) != 1 { + t.Errorf("Discover made %d calls, want 1: %v", len(files.calls), files.calls) + } +} + +func TestDiscoverFallsBackInGitHubsOrder(t *testing.T) { + cases := map[string]struct { + exists string + want string + calls int + }{ + "github dir": {".github/FUNDING.yml", "github: a", 1}, + "root": {"FUNDING.yml", "github: a", 2}, + "docs": {"docs/FUNDING.yml", "github: a", 3}, + "owner .github": {"OWNER:.github/FUNDING.yml", "github: a", 4}, + "owner root": {"OWNER:FUNDING.yml", "github: a", 5}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + key := "alice/project/" + tc.exists + "@main" + if handle, ok := strings.CutPrefix(tc.exists, "OWNER:"); ok { + // The owner's .github repository is read from its own default + // branch: a ref from owner/repo does not exist there. + key = "alice/.github/" + handle + "@" + } + files := &fakeFiles{files: map[string]string{key: tc.want}} + links, err := Discover(context.Background(), files, "alice", "project", "main") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if !reflect.DeepEqual(links.GitHub, []string{"a"}) { + t.Errorf("Discover = %+v, want the %s file", links, name) + } + if len(files.calls) != tc.calls { + t.Errorf("Discover made %d calls, want %d: %v", len(files.calls), tc.calls, files.calls) + } + }) + } +} + +func TestDiscoverReturnsEmptyWhenNothingIsFunded(t *testing.T) { + files := &fakeFiles{} + links, err := Discover(context.Background(), files, "alice", "project", "") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if !links.Empty() { + t.Errorf("Discover = %+v, want empty", links) + } + // With an empty ref the default branch is read, which is how the call is + // forwarded to the forge. + for _, call := range files.calls { + if !strings.HasSuffix(call, "@") { + t.Errorf("call %q does not pass the empty ref through", call) + } + } +} + +func TestDiscoverStopsAtAFileThatDeclaresNothing(t *testing.T) { + // Existence decides which file GitHub reads, so a file with a key this + // package does not know stops the walk instead of falling through to docs/. + files := &fakeFiles{files: map[string]string{ + "alice/project/FUNDING.yml@main": "unknown_platform: alice\n", + "alice/project/docs/FUNDING.yml@main": "github: alice\n", + }} + links, err := Discover(context.Background(), files, "alice", "project", "main") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if !links.Empty() { + t.Errorf("Discover = %+v, want empty", links) + } + if len(files.calls) != 2 { + t.Errorf("Discover made %d calls, want 2: %v", len(files.calls), files.calls) + } +} + +func TestDiscoverTreatsAnUnsupportedForgeAsMissing(t *testing.T) { + files := &fakeFiles{errs: map[string]error{ + "alice/project/.github/FUNDING.yml@main": forges.ErrNotSupported, + "alice/project/FUNDING.yml@main": forges.ErrNotSupported, + "alice/project/docs/FUNDING.yml@main": forges.ErrNotSupported, + "alice/.github/.github/FUNDING.yml@": forges.ErrNotSupported, + "alice/.github/FUNDING.yml@": forges.ErrNotSupported, + }} + links, err := Discover(context.Background(), files, "alice", "project", "main") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if !links.Empty() { + t.Errorf("Discover = %+v, want empty", links) + } +} + +func TestDiscoverReturnsOtherErrors(t *testing.T) { + files := &fakeFiles{errs: map[string]error{ + "alice/project/.github/FUNDING.yml@main": &forges.HTTPError{StatusCode: http.StatusForbidden}, + }} + if _, err := Discover(context.Background(), files, "alice", "project", "main"); err == nil { + t.Fatal("Discover returned no error for a 403") + } + + // A 404 is a missing file, wherever it is reported. + files = &fakeFiles{errs: map[string]error{ + "alice/project/.github/FUNDING.yml@main": &forges.HTTPError{StatusCode: http.StatusNotFound}, + }, files: map[string]string{"alice/project/FUNDING.yml@main": "github: alice\n"}} + links, err := Discover(context.Background(), files, "alice", "project", "main") + if err != nil { + t.Fatalf("Discover: %v", err) + } + if !reflect.DeepEqual(links.GitHub, []string{"alice"}) { + t.Errorf("Discover = %+v, want the root file", links) + } +} + +func TestDiscoverRequiresAFileService(t *testing.T) { + if _, err := Discover(context.Background(), nil, "alice", "project", "main"); err == nil { + t.Fatal("Discover(nil) returned no error") + } +} From 25c7d73ed3f7a393106b9174c7d3981988edbac7 Mon Sep 17 00:00:00 2001 From: Paolo Valletta Date: Sat, 31 Oct 2026 15:07:03 +0100 Subject: [PATCH 2/3] Parse FUNDING.yml with go.yaml.in/yaml/v3 Replace the hand-written FUNDING.yml parser with a YAML unmarshal into Links. github and custom accept a single string or a list, so both use a small stringOrList type with UnmarshalYAML that handles either; an unknown key with a block list and a flow list split across lines now parse instead of failing, and the scanning code goes away. URL no longer takes a Links receiver, Location becomes the unexported location, and isMissing drops its HTTPError branch, since every backend maps a 404 to ErrNotFound. --- funding/funding.go | 259 ++++++++++------------------------------ funding/funding_test.go | 62 ++++++---- go.mod | 1 + go.sum | 2 + 4 files changed, 104 insertions(+), 220 deletions(-) diff --git a/funding/funding.go b/funding/funding.go index 9d1a729..506befa 100644 --- a/funding/funding.go +++ b/funding/funding.go @@ -4,36 +4,31 @@ package funding import ( - "bytes" "context" "errors" "fmt" - "net/http" - "strings" + + "go.yaml.in/yaml/v3" forges "github.com/git-pkgs/forge" ) // Links is the parsed content of a FUNDING.yml file. -// -// The field names follow the file's keys, not GitHub's button labels. A file -// may declare several destinations, and each one is a handle the platform -// understands, except Custom, which holds URLs. type Links struct { - GitHub []string `json:"github,omitempty"` - Patreon string `json:"patreon,omitempty"` - OpenCollective string `json:"open_collective,omitempty"` - KoFi string `json:"ko_fi,omitempty"` - Tidelift string `json:"tidelift,omitempty"` - CommunityBridge string `json:"community_bridge,omitempty"` - Liberapay string `json:"liberapay,omitempty"` - IssueHunt string `json:"issuehunt,omitempty"` - LFXCrowdfunding string `json:"lfx_crowdfunding,omitempty"` - Polar string `json:"polar,omitempty"` - BuyMeACoffee string `json:"buy_me_a_coffee,omitempty"` - ThanksDev string `json:"thanks_dev,omitempty"` - Otechie string `json:"otechie,omitempty"` - Custom []string `json:"custom,omitempty"` + GitHub stringOrList `yaml:"github" json:"github,omitempty"` + Patreon string `yaml:"patreon" json:"patreon,omitempty"` + OpenCollective string `yaml:"open_collective" json:"open_collective,omitempty"` + KoFi string `yaml:"ko_fi" json:"ko_fi,omitempty"` + Tidelift string `yaml:"tidelift" json:"tidelift,omitempty"` + CommunityBridge string `yaml:"community_bridge" json:"community_bridge,omitempty"` + Liberapay string `yaml:"liberapay" json:"liberapay,omitempty"` + IssueHunt string `yaml:"issuehunt" json:"issuehunt,omitempty"` + LFXCrowdfunding string `yaml:"lfx_crowdfunding" json:"lfx_crowdfunding,omitempty"` + Polar string `yaml:"polar" json:"polar,omitempty"` + BuyMeACoffee string `yaml:"buy_me_a_coffee" json:"buy_me_a_coffee,omitempty"` + ThanksDev string `yaml:"thanks_dev" json:"thanks_dev,omitempty"` + Otechie string `yaml:"otechie" json:"otechie,omitempty"` + Custom stringOrList `yaml:"custom" json:"custom,omitempty"` } // Empty reports whether the file declared no funding destination. @@ -45,9 +40,9 @@ func (l Links) Empty() bool { l.BuyMeACoffee == "" && l.ThanksDev == "" && l.Otechie == "" } -// URL returns the funding page GitHub links for a handle, or an empty string -// for Custom: those entries are already URLs and are returned unchanged. -func (l Links) URL(platform, handle string) string { +// URL returns the page GitHub links for a handle on a platform, or an empty +// string for a platform it does not know. Custom entries are already URLs. +func URL(platform, handle string) string { switch platform { case "github": return "https://github.com/sponsors/" + handle @@ -79,60 +74,46 @@ func (l Links) URL(platform, handle string) string { return "" } -// locations is where GitHub looks for a funding file, in order: the .github -// directory, the repository root, docs/, and finally the owner's .github -// repository. Discovery stops at the first file it finds, even when that file -// declares nothing: existence, not content, decides which file GitHub reads. -var locations = []Location{ - {Path: ".github/FUNDING.yml"}, - {Path: "FUNDING.yml"}, - {Path: "docs/FUNDING.yml"}, - {Repo: ".github", Path: ".github/FUNDING.yml"}, - {Repo: ".github", Path: "FUNDING.yml"}, +// locations is where GitHub looks for a funding file, in order. +var locations = []location{ + {path: ".github/FUNDING.yml"}, + {path: "FUNDING.yml"}, + {path: "docs/FUNDING.yml"}, + {repo: ".github", path: ".github/FUNDING.yml"}, + {repo: ".github", path: "FUNDING.yml"}, } -// Location is one file a funding file may live in. An empty Repo means the -// repository being asked about; a set one names another repository of the same -// owner, which is how the owner's .github repository is described. -type Location struct { - Repo string - Path string +// location is one file a funding file may live in. A set repo names another +// repository of the same owner, read from its own default branch. +type location struct { + repo string + path string } -// Discover reads the funding file GitHub would read for owner/repo. ref is the -// branch or commit to read from; empty means the repository's default branch. -// The owner's .github repository is always read from its own default branch, -// because a tag or branch name from owner/repo does not exist there. -// -// A file that is not there is not an error: Discover moves on to the next -// location and returns empty Links when none of them has one. A forge that -// cannot read files at all (ErrNotSupported, which the Tangled and Bitbucket -// backends return for every Get) is treated the same way. Any other error — an -// authentication failure, a rate limit — is returned, wrapped, because it says -// nothing about whether the repository is funded; errors.Is and errors.As see -// through the wrap. +// Discover reads the funding file GitHub would read for owner/repo; an empty +// ref reads the default branch, and a missing file moves to the next location. func Discover(ctx context.Context, files forges.FileService, owner, repo, ref string) (Links, error) { if files == nil { return Links{}, errors.New("funding: nil file service") } for _, loc := range locations { fileRepo, probeRef := repo, ref - if loc.Repo != "" { - fileRepo, probeRef = loc.Repo, "" + if loc.repo != "" { + fileRepo, probeRef = loc.repo, "" } - content, err := files.Get(ctx, owner, fileRepo, loc.Path, probeRef) + content, err := files.Get(ctx, owner, fileRepo, loc.path, probeRef) if err != nil { if isMissing(err) { continue } - return Links{}, fmt.Errorf("funding: read %s/%s %s: %w", owner, fileRepo, loc.Path, err) + return Links{}, fmt.Errorf("funding: read %s/%s %s: %w", owner, fileRepo, loc.path, err) } if content == nil { continue } links, err := Parse(content.Content) if err != nil { - return Links{}, fmt.Errorf("funding: parse %s/%s %s: %w", owner, fileRepo, loc.Path, err) + return Links{}, fmt.Errorf("funding: parse %s/%s %s: %w", owner, fileRepo, loc.path, err) } return links, nil } @@ -142,155 +123,41 @@ func Discover(ctx context.Context, files forges.FileService, owner, repo, ref st // isMissing reports whether an error means there is nothing to read: the file // is not there, or the forge has no file API at all. func isMissing(err error) bool { - if errors.Is(err, forges.ErrNotFound) || errors.Is(err, forges.ErrNotSupported) { - return true - } - var httpErr *forges.HTTPError - return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound -} - -// scalarKeys are the FUNDING.yml keys that hold a single handle. -var scalarKeys = map[string]func(*Links) *string{ - "patreon": func(l *Links) *string { return &l.Patreon }, - "open_collective": func(l *Links) *string { return &l.OpenCollective }, - "ko_fi": func(l *Links) *string { return &l.KoFi }, - "tidelift": func(l *Links) *string { return &l.Tidelift }, - "community_bridge": func(l *Links) *string { return &l.CommunityBridge }, - "liberapay": func(l *Links) *string { return &l.Liberapay }, - "issuehunt": func(l *Links) *string { return &l.IssueHunt }, - "lfx_crowdfunding": func(l *Links) *string { return &l.LFXCrowdfunding }, - "polar": func(l *Links) *string { return &l.Polar }, - "buy_me_a_coffee": func(l *Links) *string { return &l.BuyMeACoffee }, - "thanks_dev": func(l *Links) *string { return &l.ThanksDev }, - "otechie": func(l *Links) *string { return &l.Otechie }, -} - -// listKeys are the FUNDING.yml keys that hold a list of handles or URLs. -var listKeys = map[string]func(*Links) *[]string{ - "github": func(l *Links) *[]string { return &l.GitHub }, - "custom": func(l *Links) *[]string { return &l.Custom }, + return errors.Is(err, forges.ErrNotFound) || errors.Is(err, forges.ErrNotSupported) } -// Parse reads the body of a FUNDING.yml file. -// -// The format is the small flat subset GitHub accepts: one "key: value" per line, -// a value in quotes, a bare handle, an inline list "[a, b]", or a list of "- x" -// lines below the key. Comments start at "#", a "---" document marker is -// ignored, and a leading UTF-8 BOM is stripped. Unknown keys are ignored, so a -// file that uses a platform added after this package was written still parses, -// and a line that is none of those shapes is an error rather than a silent skip. -// -// Two shapes are outside this subset rather than mis-parsed into something else: -// a "#" preceded by a space inside a quoted value, and a comma inside a quoted -// inline-list item. No FUNDING.yml seen in the wild uses either. +// Parse reads the body of a FUNDING.yml file. Keys this package does not know +// are ignored, so a platform added later still parses. func Parse(content []byte) (Links, error) { var links Links - var pending *[]string // the list a "key:" line without a value opens - - content = bytes.TrimPrefix(content, []byte("\ufeff")) - - flush := func() { - pending = nil - } - - for number, raw := range strings.Split(string(content), "\n") { - line := strings.TrimRight(raw, " \t\r") - line = stripComment(line) - trimmed := strings.TrimLeft(line, " \t") - if trimmed == "" || trimmed == "---" || trimmed == "..." { - continue - } - - if strings.HasPrefix(trimmed, "- ") || trimmed == "-" { - if pending == nil { - return Links{}, fmt.Errorf("line %d: list item outside a list key", number+1) - } - item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) - if item == "" { - return Links{}, fmt.Errorf("line %d: empty list item", number+1) - } - *pending = append(*pending, unquote(item)) - continue - } - - key, value, ok := strings.Cut(trimmed, ":") - if !ok { - return Links{}, fmt.Errorf("line %d: want \"key: value\", got %q", number+1, trimmed) - } - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - flush() - - if setter, known := scalarKeys[key]; known { - if value == "" { - continue - } - *setter(&links) = unquote(value) - continue - } - if setter, known := listKeys[key]; known { - list := setter(&links) - switch { - case value == "": - pending = list - case strings.HasPrefix(value, "["): - items, err := parseInlineList(value) - if err != nil { - return Links{}, fmt.Errorf("line %d: %w", number+1, err) - } - *list = append(*list, items...) - default: - *list = append(*list, unquote(value)) - } - continue - } - // An unknown key: a platform this package does not know about yet. + if err := yaml.Unmarshal(content, &links); err != nil { + return Links{}, err } return links, nil } -// stripComment removes a "#" comment. A "#" only starts a comment at the start -// of the line or after whitespace, so a URL fragment survives. -func stripComment(line string) string { - for i := 0; i < len(line); i++ { - if line[i] != '#' { - continue - } - if i == 0 || line[i-1] == ' ' || line[i-1] == '\t' { - return line[:i] - } - } - return line -} +// stringOrList is a value GitHub accepts either as one entry or as a list of +// them: "github" and "custom" both allow either. +type stringOrList []string -// parseInlineList reads "[a, b, 'c']". -func parseInlineList(value string) ([]string, error) { - if !strings.HasSuffix(value, "]") { - return nil, fmt.Errorf("unterminated list %q", value) - } - inner := strings.TrimSpace(value[1 : len(value)-1]) - if inner == "" { - return nil, nil - } - parts := strings.Split(inner, ",") - items := make([]string, 0, len(parts)) - for _, part := range parts { - item := unquote(strings.TrimSpace(part)) - if item == "" { - return nil, fmt.Errorf("empty item in list %q", value) +func (l *stringOrList) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + var value string + if err := node.Decode(&value); err != nil { + return err } - items = append(items, item) - } - return items, nil -} - -// unquote removes one pair of matching quotes. -func unquote(value string) string { - if len(value) >= 2 { - if (value[0] == '"' && value[len(value)-1] == '"') || - (value[0] == '\'' && value[len(value)-1] == '\'') { - return value[1 : len(value)-1] + if value != "" { + *l = stringOrList{value} + } + return nil + case yaml.SequenceNode: + var values []string + if err := node.Decode(&values); err != nil { + return err } + *l = values + return nil } - return value + return fmt.Errorf("want a string or a list, got %s", node.Tag) } diff --git a/funding/funding_test.go b/funding/funding_test.go index 34f2c38..d1e7fd3 100644 --- a/funding/funding_test.go +++ b/funding/funding_test.go @@ -62,7 +62,7 @@ unknown_platform: ignored t.Fatalf("Parse: %v", err) } want := Links{ - GitHub: []string{"alice", "bob"}, + GitHub: stringOrList{"alice", "bob"}, Patreon: "alice", OpenCollective: "my-collective", KoFi: "alice", @@ -75,7 +75,7 @@ unknown_platform: ignored BuyMeACoffee: "alice", ThanksDev: "gh/alice", Otechie: "alice", - Custom: []string{"https://example.com/donate", "https://example.org/#giving"}, + Custom: stringOrList{"https://example.com/donate", "https://example.org/#giving"}, } if !reflect.DeepEqual(links, want) { t.Errorf("Parse:\n got %+v\nwant %+v", links, want) @@ -98,11 +98,11 @@ patreon: alice # a trailing comment if err != nil { t.Fatalf("Parse: %v", err) } - if !reflect.DeepEqual(links.GitHub, []string{"alice", "bob"}) { + if !reflect.DeepEqual(links.GitHub, stringOrList{"alice", "bob"}) { t.Errorf("GitHub = %v", links.GitHub) } - // A "#" inside a URL is not a comment, so the fragment survives. - if !reflect.DeepEqual(links.Custom, []string{"https://example.com/donate", "https://example.org/docs#donate"}) { + // A "#" inside a URL does not start a comment, so the fragment survives. + if !reflect.DeepEqual(links.Custom, stringOrList{"https://example.com/donate", "https://example.org/docs#donate"}) { t.Errorf("Custom = %v", links.Custom) } if links.Patreon != "alice" { @@ -131,18 +131,44 @@ func TestParseSkipsABOMAndADocumentMarker(t *testing.T) { if err != nil { t.Fatalf("Parse: %v", err) } - if !reflect.DeepEqual(links.GitHub, []string{"alice"}) { + if !reflect.DeepEqual(links.GitHub, stringOrList{"alice"}) { t.Errorf("GitHub = %v, want [alice]", links.GitHub) } } +func TestParseReadsGitHubsYAMLShapes(t *testing.T) { + for name, tc := range map[string]struct { + content string + want Links + }{ + "unknown key with a block list": { + content: "new_platform:\n - a\n - b\ngithub: alice\n", + want: Links{GitHub: stringOrList{"alice"}}, + }, + "flow list split across lines": { + content: "custom: [\"https://a.example\",\n \"https://b.example\"]\n", + want: Links{Custom: stringOrList{"https://a.example", "https://b.example"}}, + }, + } { + t.Run(name, func(t *testing.T) { + links, err := Parse([]byte(tc.content)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if !reflect.DeepEqual(links, tc.want) { + t.Errorf("Parse:\n got %+v\nwant %+v", links, tc.want) + } + }) + } +} + func TestParseHandlesCRLF(t *testing.T) { content := "github: [alice, bob]\r\npatreon: alice\r\n" links, err := Parse([]byte(content)) if err != nil { t.Fatalf("Parse: %v", err) } - if !reflect.DeepEqual(links.GitHub, []string{"alice", "bob"}) || links.Patreon != "alice" { + if !reflect.DeepEqual(links.GitHub, stringOrList{"alice", "bob"}) || links.Patreon != "alice" { t.Errorf("Parse = %+v", links) } } @@ -158,7 +184,6 @@ func TestParseEmptyContentIsEmpty(t *testing.T) { } func TestURL(t *testing.T) { - links := Links{} for platform, want := range map[string]string{ "github": "https://github.com/sponsors/alice", "patreon": "https://www.patreon.com/alice", @@ -172,13 +197,14 @@ func TestURL(t *testing.T) { "polar": "https://polar.sh/alice", "buy_me_a_coffee": "https://www.buymeacoffee.com/alice", "thanks_dev": "https://thanks.dev/alice", + "otechie": "https://otechie.com/alice", } { - if got := links.URL(platform, "alice"); got != want { + if got := URL(platform, "alice"); got != want { t.Errorf("URL(%q) = %q, want %q", platform, got, want) } } // Custom holds URLs already, so there is nothing to build. - if got := links.URL("custom", "https://example.com"); got != "" { + if got := URL("custom", "https://example.com"); got != "" { t.Errorf("URL(custom) = %q, want empty", got) } } @@ -192,7 +218,7 @@ func TestDiscoverPrefersTheGitHubDirectory(t *testing.T) { if err != nil { t.Fatalf("Discover: %v", err) } - if !reflect.DeepEqual(links.GitHub, []string{"alice"}) || links.Patreon != "" { + if !reflect.DeepEqual(links.GitHub, stringOrList{"alice"}) || links.Patreon != "" { t.Errorf("Discover = %+v, want the .github directory file", links) } if len(files.calls) != 1 { @@ -225,7 +251,7 @@ func TestDiscoverFallsBackInGitHubsOrder(t *testing.T) { if err != nil { t.Fatalf("Discover: %v", err) } - if !reflect.DeepEqual(links.GitHub, []string{"a"}) { + if !reflect.DeepEqual(links.GitHub, stringOrList{"a"}) { t.Errorf("Discover = %+v, want the %s file", links, name) } if len(files.calls) != tc.calls { @@ -296,18 +322,6 @@ func TestDiscoverReturnsOtherErrors(t *testing.T) { if _, err := Discover(context.Background(), files, "alice", "project", "main"); err == nil { t.Fatal("Discover returned no error for a 403") } - - // A 404 is a missing file, wherever it is reported. - files = &fakeFiles{errs: map[string]error{ - "alice/project/.github/FUNDING.yml@main": &forges.HTTPError{StatusCode: http.StatusNotFound}, - }, files: map[string]string{"alice/project/FUNDING.yml@main": "github: alice\n"}} - links, err := Discover(context.Background(), files, "alice", "project", "main") - if err != nil { - t.Fatalf("Discover: %v", err) - } - if !reflect.DeepEqual(links.GitHub, []string{"alice"}) { - t.Errorf("Discover = %+v, want the root file", links) - } } func TestDiscoverRequiresAFileService(t *testing.T) { diff --git a/go.mod b/go.mod index 95ba697..d2e7d96 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 gitlab.com/gitlab-org/api/client-go v1.46.0 + go.yaml.in/yaml/v3 v3.0.5 golang.org/x/crypto v0.58.0 golang.org/x/term v0.47.0 ) diff --git a/go.sum b/go.sum index 994327d..21f11f4 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= From b014e4e6a58605db2e2fbaf68f6739139748bb97 Mon Sep 17 00:00:00 2001 From: Paolo Valletta Date: Mon, 2 Nov 2026 08:57:15 +0100 Subject: [PATCH 3/3] Export the string-or-list type as StringList --- funding/funding.go | 36 ++++++++++++++++++------------------ funding/funding_test.go | 20 ++++++++++---------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/funding/funding.go b/funding/funding.go index 506befa..5530ab6 100644 --- a/funding/funding.go +++ b/funding/funding.go @@ -15,20 +15,20 @@ import ( // Links is the parsed content of a FUNDING.yml file. type Links struct { - GitHub stringOrList `yaml:"github" json:"github,omitempty"` - Patreon string `yaml:"patreon" json:"patreon,omitempty"` - OpenCollective string `yaml:"open_collective" json:"open_collective,omitempty"` - KoFi string `yaml:"ko_fi" json:"ko_fi,omitempty"` - Tidelift string `yaml:"tidelift" json:"tidelift,omitempty"` - CommunityBridge string `yaml:"community_bridge" json:"community_bridge,omitempty"` - Liberapay string `yaml:"liberapay" json:"liberapay,omitempty"` - IssueHunt string `yaml:"issuehunt" json:"issuehunt,omitempty"` - LFXCrowdfunding string `yaml:"lfx_crowdfunding" json:"lfx_crowdfunding,omitempty"` - Polar string `yaml:"polar" json:"polar,omitempty"` - BuyMeACoffee string `yaml:"buy_me_a_coffee" json:"buy_me_a_coffee,omitempty"` - ThanksDev string `yaml:"thanks_dev" json:"thanks_dev,omitempty"` - Otechie string `yaml:"otechie" json:"otechie,omitempty"` - Custom stringOrList `yaml:"custom" json:"custom,omitempty"` + GitHub StringList `yaml:"github" json:"github,omitempty"` + Patreon string `yaml:"patreon" json:"patreon,omitempty"` + OpenCollective string `yaml:"open_collective" json:"open_collective,omitempty"` + KoFi string `yaml:"ko_fi" json:"ko_fi,omitempty"` + Tidelift string `yaml:"tidelift" json:"tidelift,omitempty"` + CommunityBridge string `yaml:"community_bridge" json:"community_bridge,omitempty"` + Liberapay string `yaml:"liberapay" json:"liberapay,omitempty"` + IssueHunt string `yaml:"issuehunt" json:"issuehunt,omitempty"` + LFXCrowdfunding string `yaml:"lfx_crowdfunding" json:"lfx_crowdfunding,omitempty"` + Polar string `yaml:"polar" json:"polar,omitempty"` + BuyMeACoffee string `yaml:"buy_me_a_coffee" json:"buy_me_a_coffee,omitempty"` + ThanksDev string `yaml:"thanks_dev" json:"thanks_dev,omitempty"` + Otechie string `yaml:"otechie" json:"otechie,omitempty"` + Custom StringList `yaml:"custom" json:"custom,omitempty"` } // Empty reports whether the file declared no funding destination. @@ -136,11 +136,11 @@ func Parse(content []byte) (Links, error) { return links, nil } -// stringOrList is a value GitHub accepts either as one entry or as a list of +// StringList is a value GitHub accepts either as one entry or as a list of // them: "github" and "custom" both allow either. -type stringOrList []string +type StringList []string -func (l *stringOrList) UnmarshalYAML(node *yaml.Node) error { +func (l *StringList) UnmarshalYAML(node *yaml.Node) error { switch node.Kind { case yaml.ScalarNode: var value string @@ -148,7 +148,7 @@ func (l *stringOrList) UnmarshalYAML(node *yaml.Node) error { return err } if value != "" { - *l = stringOrList{value} + *l = StringList{value} } return nil case yaml.SequenceNode: diff --git a/funding/funding_test.go b/funding/funding_test.go index d1e7fd3..7f295e1 100644 --- a/funding/funding_test.go +++ b/funding/funding_test.go @@ -62,7 +62,7 @@ unknown_platform: ignored t.Fatalf("Parse: %v", err) } want := Links{ - GitHub: stringOrList{"alice", "bob"}, + GitHub: StringList{"alice", "bob"}, Patreon: "alice", OpenCollective: "my-collective", KoFi: "alice", @@ -75,7 +75,7 @@ unknown_platform: ignored BuyMeACoffee: "alice", ThanksDev: "gh/alice", Otechie: "alice", - Custom: stringOrList{"https://example.com/donate", "https://example.org/#giving"}, + Custom: StringList{"https://example.com/donate", "https://example.org/#giving"}, } if !reflect.DeepEqual(links, want) { t.Errorf("Parse:\n got %+v\nwant %+v", links, want) @@ -98,11 +98,11 @@ patreon: alice # a trailing comment if err != nil { t.Fatalf("Parse: %v", err) } - if !reflect.DeepEqual(links.GitHub, stringOrList{"alice", "bob"}) { + if !reflect.DeepEqual(links.GitHub, StringList{"alice", "bob"}) { t.Errorf("GitHub = %v", links.GitHub) } // A "#" inside a URL does not start a comment, so the fragment survives. - if !reflect.DeepEqual(links.Custom, stringOrList{"https://example.com/donate", "https://example.org/docs#donate"}) { + if !reflect.DeepEqual(links.Custom, StringList{"https://example.com/donate", "https://example.org/docs#donate"}) { t.Errorf("Custom = %v", links.Custom) } if links.Patreon != "alice" { @@ -131,7 +131,7 @@ func TestParseSkipsABOMAndADocumentMarker(t *testing.T) { if err != nil { t.Fatalf("Parse: %v", err) } - if !reflect.DeepEqual(links.GitHub, stringOrList{"alice"}) { + if !reflect.DeepEqual(links.GitHub, StringList{"alice"}) { t.Errorf("GitHub = %v, want [alice]", links.GitHub) } } @@ -143,11 +143,11 @@ func TestParseReadsGitHubsYAMLShapes(t *testing.T) { }{ "unknown key with a block list": { content: "new_platform:\n - a\n - b\ngithub: alice\n", - want: Links{GitHub: stringOrList{"alice"}}, + want: Links{GitHub: StringList{"alice"}}, }, "flow list split across lines": { content: "custom: [\"https://a.example\",\n \"https://b.example\"]\n", - want: Links{Custom: stringOrList{"https://a.example", "https://b.example"}}, + want: Links{Custom: StringList{"https://a.example", "https://b.example"}}, }, } { t.Run(name, func(t *testing.T) { @@ -168,7 +168,7 @@ func TestParseHandlesCRLF(t *testing.T) { if err != nil { t.Fatalf("Parse: %v", err) } - if !reflect.DeepEqual(links.GitHub, stringOrList{"alice", "bob"}) || links.Patreon != "alice" { + if !reflect.DeepEqual(links.GitHub, StringList{"alice", "bob"}) || links.Patreon != "alice" { t.Errorf("Parse = %+v", links) } } @@ -218,7 +218,7 @@ func TestDiscoverPrefersTheGitHubDirectory(t *testing.T) { if err != nil { t.Fatalf("Discover: %v", err) } - if !reflect.DeepEqual(links.GitHub, stringOrList{"alice"}) || links.Patreon != "" { + if !reflect.DeepEqual(links.GitHub, StringList{"alice"}) || links.Patreon != "" { t.Errorf("Discover = %+v, want the .github directory file", links) } if len(files.calls) != 1 { @@ -251,7 +251,7 @@ func TestDiscoverFallsBackInGitHubsOrder(t *testing.T) { if err != nil { t.Fatalf("Discover: %v", err) } - if !reflect.DeepEqual(links.GitHub, stringOrList{"a"}) { + if !reflect.DeepEqual(links.GitHub, StringList{"a"}) { t.Errorf("Discover = %+v, want the %s file", links, name) } if len(files.calls) != tc.calls {