diff --git a/README.md b/README.md index 27acb62..799e520 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ The curated list of [goblog](https://github.com/goblogplatform/goblog) plugins behind [goblog.live/plugins](https://goblog.live/plugins). - `registry.yaml` — the list. Add your repository in a PR; see [docs/CONTRACT.md](docs/CONTRACT.md). -- `https://goblogplatform.github.io/plugins/index.json` — the machine-readable index (latest release of each plugin, with `download_url` and `sha256`); `plugins/.json` adds the rendered README, changelog and release history. +- `https://goblogplatform.github.io/plugins/index.json` — the machine-readable index (latest release of each plugin, with `download_url` and `sha256`) and `stars` (GitHub stargazers, the directory's default ordering); `plugins/.json` adds the rendered README, changelog and release history. - `cmd/registry` — the tool CI runs: `validate` on pull requests, `build` on merge and every six hours. ```bash diff --git a/cmd/registry/main_test.go b/cmd/registry/main_test.go index b7f32bb..62af1b3 100644 --- a/cmd/registry/main_test.go +++ b/cmd/registry/main_test.go @@ -33,6 +33,7 @@ func (m *memSource) File(_ context.Context, owner, repo, ref, path string) ([]by func (m *memSource) RenderMarkdown(_ context.Context, _, md string) (string, error) { return "

" + md + "

", nil } +func (m *memSource) RepoStars(context.Context, string, string) (int, error) { return 3, nil } type okValidator struct{} diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index e714e44..3ebc097 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -2,6 +2,10 @@ The directory at [goblog.live/plugins](https://goblog.live/plugins) lists plugins from this registry. A plugin is a GitHub repository; each GitHub release is a version. Submitting means adding your repository to `registry.yaml` in a pull request — CI validates it and, once merged, the index is rebuilt (on every merge and every six hours). +## What the directory publishes + +The index entry for your plugin is built from the manifest, the latest release, and your repository's GitHub star count (`stars`), which the directory uses for its default ordering. Stars are best-effort: if GitHub cannot be reached for them, the entry is published with `0`. + ## What the repository must contain At the root of the repository, at the release tag being published (the tool checks the latest release): diff --git a/internal/registry/build.go b/internal/registry/build.go index b7f6ef0..9c6412a 100644 --- a/internal/registry/build.go +++ b/internal/registry/build.go @@ -30,6 +30,7 @@ type IndexEntry struct { InstallType string `json:"install_type"` ReleasedAt string `json:"released_at"` DetailURL string `json:"detail_url"` + Stars int `json:"stars"` } // ReleaseDoc is one release in a plugin's history. @@ -142,6 +143,14 @@ func buildDetail(ctx context.Context, src Source, v *Validated, baseURL string) DetailURL: fmt.Sprintf("%s/plugins/%s.json", baseURL, v.Manifest.Name), } + // Stars only order the directory; a failed lookup must not drop an + // otherwise valid plugin from the index. + if stars, err := src.RepoStars(ctx, v.Owner, v.Name); err != nil { + log.Printf("%s: stars unavailable, using 0: %v", ownerRepo, err) + } else { + entry.Stars = stars + } + readme, err := src.File(ctx, v.Owner, v.Name, v.Release.Tag, "README.md") if err != nil { return DetailDoc{}, fmt.Errorf("README.md: %w", err) diff --git a/internal/registry/build_test.go b/internal/registry/build_test.go index d64d0af..65ee891 100644 --- a/internal/registry/build_test.go +++ b/internal/registry/build_test.go @@ -3,6 +3,7 @@ package registry import ( "context" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -40,7 +41,7 @@ func TestBuild_WritesIndexAndDetails(t *testing.T) { License: "Apache-2.0", SourceURL: "https://github.com/o/hello", DownloadURL: "https://raw.githubusercontent.com/o/hello/v1.1.0/plugin.go", SHA256: sum([]byte(helloSrc)), MinGoblogVersion: "0.2.6", InstallType: "dynamic", ReleasedAt: "2026-09-15T00:00:00Z", - DetailURL: "https://example.test/plugins/plugins/hello.json", + DetailURL: "https://example.test/plugins/plugins/hello.json", Stars: 7, } if e != want { t.Errorf("entry =\n%+v\nwant\n%+v", e, want) @@ -51,6 +52,9 @@ func TestBuild_WritesIndexAndDetails(t *testing.T) { if d.Name != "hello" || d.ReadmeHTML != "

# Hello

" || d.ChangelogHTML != "

## 1.1.0\n- second

" { t.Errorf("detail = %+v", d) } + if d.Stars != 7 { + t.Errorf("detail stars = %d", d.Stars) + } detailRaw, err := os.ReadFile(filepath.Join(out, "plugins", "hello.json")) if err != nil { t.Fatal(err) @@ -115,6 +119,26 @@ func TestBuild_SkipsBrokenEntriesAndDuplicates(t *testing.T) { } } +// TestBuild_StarsAreBestEffort: a failed star lookup must not drop a valid +// plugin; the entry is published with stars 0. +func TestBuild_StarsAreBestEffort(t *testing.T) { + src := helloSource() + src.starsErr = errors.New("rate limited") + out := t.TempDir() + res, err := Build(context.Background(), src, helloValidator(), []string{"o/hello"}, out, "https://example.test/plugins") + if err != nil { + t.Fatal(err) + } + if len(res.Built) != 1 || len(res.Skipped) != 0 { + t.Fatalf("result = %+v", res) + } + var index []IndexEntry + mustJSON(t, filepath.Join(out, "index.json"), &index) + if len(index) != 1 || index[0].Stars != 0 { + t.Errorf("entry should be published with stars 0, got %+v", index) + } +} + func TestBuild_FailsWhenNothingBuilt(t *testing.T) { src := helloSource() if _, err := Build(context.Background(), src, helloValidator(), []string{"o/nope"}, t.TempDir(), "https://example.test/plugins"); err == nil { diff --git a/internal/registry/source.go b/internal/registry/source.go index 45e69b6..daf09be 100644 --- a/internal/registry/source.go +++ b/internal/registry/source.go @@ -36,6 +36,9 @@ type Source interface { // the context of ownerRepo (so `#123` and `@user` references resolve; // relative links and images are left as-is). RenderMarkdown(ctx context.Context, ownerRepo, markdown string) (string, error) + // RepoStars returns the repository's GitHub stargazer count (the + // directory's "top plugins" ordering). + RepoStars(ctx context.Context, owner, repo string) (stars int, err error) } // GitHubSource implements Source with the GitHub REST API. @@ -110,6 +113,14 @@ func (g *GitHubSource) File(ctx context.Context, owner, repo, ref, path string) return []byte(s), nil } +func (g *GitHubSource) RepoStars(ctx context.Context, owner, repo string) (int, error) { + r, _, err := g.client.Repositories.Get(ctx, owner, repo) + if err != nil { + return 0, fmt.Errorf("get repo %s/%s: %w", owner, repo, err) + } + return r.GetStargazersCount(), nil +} + func (g *GitHubSource) RenderMarkdown(ctx context.Context, ownerRepo, markdown string) (string, error) { if markdown == "" { return "", nil diff --git a/internal/registry/source_test.go b/internal/registry/source_test.go index 2c72a0e..c150269 100644 --- a/internal/registry/source_test.go +++ b/internal/registry/source_test.go @@ -23,6 +23,10 @@ func fakeGitHub(t *testing.T) *httptest.Server { {"tag_name":"v1.0.0","name":"v1.0.0","body":"First","draft":false,"prerelease":false,"published_at":"2026-09-14T00:00:00Z","html_url":"https://github.com/o/r/releases/tag/v1.0.0"} ]`)) }) + mux.HandleFunc("GET /repos/o/r", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"full_name":"o/r","stargazers_count":42}`)) + }) mux.HandleFunc("GET /repos/o/r/contents/plugin.go", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("ref") != "v1.1.0" { http.NotFound(w, r) @@ -84,6 +88,14 @@ func TestGitHubSource(t *testing.T) { if html, err := src.RenderMarkdown(ctx, "o/r", ""); err != nil || html != "" { t.Errorf("empty markdown should render to empty string without a request, got %q %v", html, err) } + + stars, err := src.RepoStars(ctx, "o", "r") + if err != nil || stars != 42 { + t.Errorf("RepoStars = %d, %v", stars, err) + } + if _, err := src.RepoStars(ctx, "o", "missing"); err == nil { + t.Error("RepoStars on an unknown repo should fail") + } } func jsonDecode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decode(v) } diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go index 04d05e2..b91b6b4 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -13,6 +13,8 @@ type memSource struct { releases map[string][]Release // "owner/repo" → releases files map[string]string // "owner/repo@ref:path" → content rendered int // RenderMarkdown call count + stars map[string]int // "owner/repo" → stargazers_count + starsErr error // when set, RepoStars fails for every repo } func (m *memSource) Releases(_ context.Context, owner, repo string) ([]Release, error) { @@ -38,6 +40,16 @@ func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (str return "

" + md + "

", nil } +func (m *memSource) RepoStars(_ context.Context, owner, repo string) (int, error) { + if m.starsErr != nil { + return 0, m.starsErr + } + if n, ok := m.stars[owner+"/"+repo]; ok { + return n, nil + } + return 0, nil +} + const helloSrc = "package main\n// hello plugin\n" func helloSource() *memSource { @@ -56,6 +68,7 @@ func helloSource() *memSource { "o/hello@v1.1.0:README.md": "# Hello", "o/hello@v1.1.0:CHANGELOG.md": "## 1.1.0\n- second", }, + stars: map[string]int{"o/hello": 7}, } }