Files
Bohdan Triapitsyn 1ed3f1f575 feat(skills): curated GitHub catalog redesign (#3016)
* feat(skills): remove ClawHub catalog integration

Drop the ClawHub registry as a skills catalog source across web server,
shared UI, VS Code, docs, and locales. The catalog now serves git-based
sources only: the curated Anthropic repo and user-defined repositories.
Also removes the now-unused adm-zip dependency.

* feat(skills): redesign catalog around curated GitHub repositories

Replace the single-source dropdown with a card grid of curated GitHub
repositories (Anthropic, OpenAI, Cursor pstack/skills, Matt Pocock) plus
user-defined sources. Source cards show skill counts, GitHub stars, and
last-updated time; a global search covers all loaded sources.

Server: curated sources gain GitHub repo metadata (stars, pushed_at)
fetched best-effort with a 3-hour in-memory and on-disk cache; scans
run through a concurrency-limited, deduplicated cache with 3-hour TTL
persisted across restarts. Refresh still bypasses the cache.

Shared UI: source cards, global search with clear button, per-skill
GitHub links, install/installed states. VS Code curated list updated
to match. All new copy translated across 12 locales.

* fix(skills): address catalog review findings

- GitHub metadata fetch timeout drops to 1.5s (under the catalog
  client's 3s deadline) and failed lookups cache briefly (5 min) so
  repeated catalog loads do not re-hit a failing API.
- Disk cache files are written with owner-only permissions (0o600);
  rename preserves the mode.
- loadSource deduplicates concurrent in-flight requests per source and
  the shared isLoadingSource flag now clears only when the last active
  source load finishes.
2026-08-20 01:40:10 +03:00

72 lines
2.3 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { clearGitHubMetaCache, fetchGitHubRepoMetas } from './github-meta.js';
const originalFetch = globalThis.fetch;
let tempDataDir;
beforeEach(() => {
tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'github-meta-test-'));
process.env.OPENCHAMBER_DATA_DIR = tempDataDir;
});
afterEach(() => {
delete process.env.OPENCHAMBER_DATA_DIR;
globalThis.fetch = originalFetch;
clearGitHubMetaCache();
vi.restoreAllMocks();
fs.rmSync(tempDataDir, { recursive: true, force: true });
});
describe('fetchGitHubRepoMetas', () => {
it('returns stars and pushed_at from the GitHub API', async () => {
const fetchMock = vi.fn(async () => new Response(
JSON.stringify({ stargazers_count: 42, pushed_at: '2026-08-01T00:00:00Z' }),
{ status: 200 },
));
globalThis.fetch = fetchMock;
const metas = await fetchGitHubRepoMetas(['anthropics/skills']);
expect(metas).toEqual({
'anthropics/skills': { stars: 42, repoUpdatedAt: '2026-08-01T00:00:00Z' },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('resolves failed lookups to null without throwing', async () => {
globalThis.fetch = vi.fn(async () => new Response('rate limited', { status: 403 }));
const metas = await fetchGitHubRepoMetas(['anthropics/skills']);
expect(metas).toEqual({ 'anthropics/skills': null });
});
it('caches failed lookups briefly to avoid repeat hits', async () => {
const fetchMock = vi.fn(async () => new Response('rate limited', { status: 403 }));
globalThis.fetch = fetchMock;
await fetchGitHubRepoMetas(['anthropics/skills']);
const second = await fetchGitHubRepoMetas(['anthropics/skills']);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(second).toEqual({ 'anthropics/skills': { stars: null, repoUpdatedAt: null } });
});
it('deduplicates repositories', async () => {
const fetchMock = vi.fn(async () => new Response(
JSON.stringify({ stargazers_count: 1, pushed_at: null }),
{ status: 200 },
));
globalThis.fetch = fetchMock;
const metas = await fetchGitHubRepoMetas(['a/b', 'a/b', null]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(metas['a/b']).toEqual({ stars: 1, repoUpdatedAt: null });
});
});