From 6f50cef2d65b1ec3efe19f7cade5fa3389c47b77 Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 17 Apr 2026 18:30:39 +0200 Subject: [PATCH] feat(marketplace): sort GitHub skill search by stars descending GitHub Code Search returned results in best-match order so low-star repos appeared above popular ones. Sort SearchResult slice by Stars desc (stable) before caching and returning, so the marketplace UI always lists highest-starred skills first. Added apiBaseURL on MarketplaceService to inject httptest server in unit tests. --- internal/github/marketplace.go | 14 +++- internal/github/marketplace_test.go | 79 +++++++++++++++++++ .../tests/e2e/github-stars-sort.spec.ts | 42 ++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 internal/github/marketplace_test.go create mode 100644 marketplace/tests/e2e/github-stars-sort.spec.ts diff --git a/internal/github/marketplace.go b/internal/github/marketplace.go index 92dc4f6..c04e0af 100644 --- a/internal/github/marketplace.go +++ b/internal/github/marketplace.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "path" + "sort" "strconv" "strings" "sync" @@ -36,6 +37,7 @@ type MarketplaceService struct { reg *registry.Registry store *store.Store cache sync.Map + apiBaseURL string // override for tests; defaults to https://api.github.com } // SearchResult represents a single result from GitHub Code Search. @@ -126,8 +128,12 @@ func (m *MarketplaceService) Search(ctx context.Context, query string, page int) // Build GitHub Code Search query: search for SKILL.md files matching the query. ghQuery := fmt.Sprintf("filename:SKILL.md %s", query) - u := fmt.Sprintf("https://api.github.com/search/code?q=%s&page=%d&per_page=20", - url.QueryEscape(ghQuery), page) + base := m.apiBaseURL + if base == "" { + base = "https://api.github.com" + } + u := fmt.Sprintf("%s/search/code?q=%s&page=%d&per_page=20", + base, url.QueryEscape(ghQuery), page) body, err := m.githubGet(ctx, u) if err != nil { @@ -169,6 +175,10 @@ func (m *MarketplaceService) Search(ctx context.Context, query string, page int) }) } + sort.SliceStable(results, func(i, j int) bool { + return results[i].Stars > results[j].Stars + }) + perPage := 30 resp := &SearchResponse{ Results: results, diff --git a/internal/github/marketplace_test.go b/internal/github/marketplace_test.go new file mode 100644 index 0000000..ce6e6ce --- /dev/null +++ b/internal/github/marketplace_test.go @@ -0,0 +1,79 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestSearch_SortsByStarsDesc verifies marketplace search returns results +// sorted by stargazers count descending regardless of upstream order. +func TestSearch_SortsByStarsDesc(t *testing.T) { + type ghRepo struct { + FullName string `json:"full_name"` + Description string `json:"description"` + StargazersCount int `json:"stargazers_count"` + } + type ghItem struct { + Name string `json:"name"` + Path string `json:"path"` + HTMLURL string `json:"html_url"` + Repository ghRepo `json:"repository"` + } + type ghResp struct { + TotalCount int `json:"total_count"` + Items []ghItem `json:"items"` + } + + upstream := ghResp{ + TotalCount: 4, + Items: []ghItem{ + {Name: "SKILL.md", Path: "skills/alpha/SKILL.md", Repository: ghRepo{FullName: "owner/alpha", StargazersCount: 10}}, + {Name: "SKILL.md", Path: "skills/beta/SKILL.md", Repository: ghRepo{FullName: "owner/beta", StargazersCount: 500}}, + {Name: "SKILL.md", Path: "skills/gamma/SKILL.md", Repository: ghRepo{FullName: "owner/gamma", StargazersCount: 0}}, + {Name: "SKILL.md", Path: "skills/delta/SKILL.md", Repository: ghRepo{FullName: "owner/delta", StargazersCount: 120}}, + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/search/code") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(upstream) + })) + defer srv.Close() + + m := &MarketplaceService{ + githubToken: "test-token", + httpClient: srv.Client(), + apiBaseURL: srv.URL, + } + + resp, err := m.Search(context.Background(), "test", 1) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + if len(resp.Results) != 4 { + t.Fatalf("expected 4 results, got %d", len(resp.Results)) + } + wantOrder := []int{500, 120, 10, 0} + for i, want := range wantOrder { + if resp.Results[i].Stars != want { + t.Errorf("position %d: got stars=%d, want %d (full order: %+v)", + i, resp.Results[i].Stars, want, starsOf(resp.Results)) + } + } +} + +func starsOf(rs []SearchResult) []int { + out := make([]int, len(rs)) + for i, r := range rs { + out[i] = r.Stars + } + return out +} diff --git a/marketplace/tests/e2e/github-stars-sort.spec.ts b/marketplace/tests/e2e/github-stars-sort.spec.ts new file mode 100644 index 0000000..420eb2c --- /dev/null +++ b/marketplace/tests/e2e/github-stars-sort.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "@playwright/test" +import type { Route } from "@playwright/test" + +// Unsorted upstream — server MUST return stars desc. +const MOCK_GITHUB_RESULTS = [ + { name: "alpha", description: "a", repo_owner: "owner", repo_name: "alpha", file_path: "skills/alpha/SKILL.md", stars: 10, html_url: "https://github.com/owner/alpha" }, + { name: "beta", description: "b", repo_owner: "owner", repo_name: "beta", file_path: "skills/beta/SKILL.md", stars: 500, html_url: "https://github.com/owner/beta" }, + { name: "gamma", description: "g", repo_owner: "owner", repo_name: "gamma", file_path: "skills/gamma/SKILL.md", stars: 0, html_url: "https://github.com/owner/gamma" }, + { name: "delta", description: "d", repo_owner: "owner", repo_name: "delta", file_path: "skills/delta/SKILL.md", stars: 120, html_url: "https://github.com/owner/delta" }, +] + +test.describe("GitHub marketplace stars sort", () => { + test("lists skills ordered by most stars first", async ({ page }) => { + // Simulate the server having already sorted by stars desc (which is the + // fix under test). Test verifies the UI renders that order faithfully. + const sorted = [...MOCK_GITHUB_RESULTS].sort((a, b) => b.stars - a.stars) + + await page.route("**/v1/github/search**", (route: Route) => { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ results: sorted, total_count: sorted.length, has_more: false }), + }) + }) + + await page.route("**/sessions/whoami", (route: Route) => + route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: 401 } }) }), + ) + + await page.goto("/github") + + const search = page.getByPlaceholder("Search GitHub for skills...") + await search.fill("test") + await search.press("Enter") + + const cards = page.locator("h3") + await expect(cards.first()).toHaveText("beta", { timeout: 5000 }) + + const names = await cards.allInnerTexts() + expect(names.slice(0, 4)).toEqual(["beta", "delta", "alpha", "gamma"]) + }) +})