From 7ac0aef8c0004afb3864cf05fb695bf9ed702cc4 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Thu, 17 Sep 2026 17:56:54 -0700 Subject: [PATCH 1/3] Add GitHub stars to the index Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- cmd/registry/main_test.go | 1 + internal/registry/build.go | 7 +++++++ internal/registry/build_test.go | 5 ++++- internal/registry/source.go | 11 +++++++++++ internal/registry/source_test.go | 12 ++++++++++++ internal/registry/validate_test.go | 9 +++++++++ 7 files changed, 45 insertions(+), 2 deletions(-) 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..1a47881 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) RepoInfo(context.Context, string, string) (int, error) { return 3, nil } type okValidator struct{} diff --git a/internal/registry/build.go b/internal/registry/build.go index b7f6ef0..3b5c12f 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,12 @@ func buildDetail(ctx context.Context, src Source, v *Validated, baseURL string) DetailURL: fmt.Sprintf("%s/plugins/%s.json", baseURL, v.Manifest.Name), } + stars, err := src.RepoInfo(ctx, v.Owner, v.Name) + if err != nil { + return DetailDoc{}, err + } + 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..04a154e 100644 --- a/internal/registry/build_test.go +++ b/internal/registry/build_test.go @@ -40,7 +40,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 +51,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) diff --git a/internal/registry/source.go b/internal/registry/source.go index 45e69b6..97f491b 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) + // RepoInfo returns the repository's GitHub stargazer count (the + // directory's "top plugins" ordering). + RepoInfo(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) RepoInfo(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..050871a 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.RepoInfo(ctx, "o", "r") + if err != nil || stars != 42 { + t.Errorf("RepoInfo = %d, %v", stars, err) + } + if _, err := src.RepoInfo(ctx, "o", "missing"); err == nil { + t.Error("RepoInfo 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..1869c92 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -13,6 +13,7 @@ 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 } func (m *memSource) Releases(_ context.Context, owner, repo string) ([]Release, error) { @@ -38,6 +39,13 @@ func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (str return "

" + md + "

", nil } +func (m *memSource) RepoInfo(_ context.Context, owner, repo string) (int, error) { + 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 +64,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}, } } From ffc0bca412bcf5ba858ee4d39bb1b90d3017cf4a Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Fri, 18 Sep 2026 08:31:45 -0700 Subject: [PATCH 2/3] Make stars best-effort and rename RepoInfo to RepoStars Co-Authored-By: Claude Opus 5 (1M context) --- cmd/registry/main_test.go | 2 +- docs/CONTRACT.md | 4 ++++ internal/registry/build.go | 10 ++++++---- internal/registry/build_test.go | 21 +++++++++++++++++++++ internal/registry/source.go | 6 +++--- internal/registry/source_test.go | 8 ++++---- internal/registry/validate_test.go | 8 ++++++-- 7 files changed, 45 insertions(+), 14 deletions(-) diff --git a/cmd/registry/main_test.go b/cmd/registry/main_test.go index 1a47881..62af1b3 100644 --- a/cmd/registry/main_test.go +++ b/cmd/registry/main_test.go @@ -33,7 +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) RepoInfo(context.Context, string, string) (int, error) { return 3, 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 3b5c12f..9c6412a 100644 --- a/internal/registry/build.go +++ b/internal/registry/build.go @@ -143,11 +143,13 @@ func buildDetail(ctx context.Context, src Source, v *Validated, baseURL string) DetailURL: fmt.Sprintf("%s/plugins/%s.json", baseURL, v.Manifest.Name), } - stars, err := src.RepoInfo(ctx, v.Owner, v.Name) - if err != nil { - return DetailDoc{}, err + // 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 } - entry.Stars = stars readme, err := src.File(ctx, v.Owner, v.Name, v.Release.Tag, "README.md") if err != nil { diff --git a/internal/registry/build_test.go b/internal/registry/build_test.go index 04a154e..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" @@ -118,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 97f491b..daf09be 100644 --- a/internal/registry/source.go +++ b/internal/registry/source.go @@ -36,9 +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) - // RepoInfo returns the repository's GitHub stargazer count (the + // RepoStars returns the repository's GitHub stargazer count (the // directory's "top plugins" ordering). - RepoInfo(ctx context.Context, owner, repo string) (stars int, err error) + RepoStars(ctx context.Context, owner, repo string) (stars int, err error) } // GitHubSource implements Source with the GitHub REST API. @@ -113,7 +113,7 @@ func (g *GitHubSource) File(ctx context.Context, owner, repo, ref, path string) return []byte(s), nil } -func (g *GitHubSource) RepoInfo(ctx context.Context, owner, repo string) (int, error) { +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) diff --git a/internal/registry/source_test.go b/internal/registry/source_test.go index 050871a..c150269 100644 --- a/internal/registry/source_test.go +++ b/internal/registry/source_test.go @@ -89,12 +89,12 @@ func TestGitHubSource(t *testing.T) { t.Errorf("empty markdown should render to empty string without a request, got %q %v", html, err) } - stars, err := src.RepoInfo(ctx, "o", "r") + stars, err := src.RepoStars(ctx, "o", "r") if err != nil || stars != 42 { - t.Errorf("RepoInfo = %d, %v", stars, err) + t.Errorf("RepoStars = %d, %v", stars, err) } - if _, err := src.RepoInfo(ctx, "o", "missing"); err == nil { - t.Error("RepoInfo on an unknown repo should fail") + if _, err := src.RepoStars(ctx, "o", "missing"); err == nil { + t.Error("RepoStars on an unknown repo should fail") } } diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go index 1869c92..decfc41 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -13,7 +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 + stars map[string]int + starsErr error // when set, RepoStars fails for every repo } func (m *memSource) Releases(_ context.Context, owner, repo string) ([]Release, error) { @@ -39,7 +40,10 @@ func (m *memSource) RenderMarkdown(_ context.Context, ownerRepo, md string) (str return "

" + md + "

", nil } -func (m *memSource) RepoInfo(_ context.Context, owner, repo string) (int, error) { +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 } From 9b0042919325009318cc26bb55fbfb35edf46873 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Fri, 18 Sep 2026 08:31:56 -0700 Subject: [PATCH 3/3] Restore the stars fake comment Co-Authored-By: Claude Opus 5 (1M context) --- internal/registry/validate_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/registry/validate_test.go b/internal/registry/validate_test.go index decfc41..b91b6b4 100644 --- a/internal/registry/validate_test.go +++ b/internal/registry/validate_test.go @@ -13,8 +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 - starsErr error // when set, RepoStars fails for every repo + 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) {