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
14 changes: 12 additions & 2 deletions internal/github/marketplace.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http"
"net/url"
"path"
"sort"
"strconv"
"strings"
"sync"
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions internal/github/marketplace_test.go
Original file line number Diff line number Diff line change
@@ -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
}
42 changes: 42 additions & 0 deletions marketplace/tests/e2e/github-stars-sort.spec.ts
Original file line number Diff line number Diff line change
@@ -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"])
})
})
Loading