agent-ecosystem/src/main/services/extensions/catalog/GitHubStarsService.ts
iliya a4210936f9 feat: enhance API key management and GitHub stars integration
- Introduced ApiKeyService for managing API keys, including listing, saving, deleting, and looking up values.
- Added IPC channels for API key operations and integrated them into the Electron API.
- Implemented GitHub stars fetching for MCP servers, enhancing visibility of repository popularity.
- Updated UI components to display GitHub stars and API key management features, including a dedicated API Keys tab.
- Enhanced McpInstallService to support custom MCP server installations with improved validation and error handling.
- Refactored various components to accommodate new features and improve user experience.
2026-03-10 20:05:04 +02:00

184 lines
5.9 KiB
TypeScript

/**
* GitHubStarsService — fetches stargazer counts from the GitHub public API.
*
* - Batch interface: accepts repository URLs, returns map of URL → stars
* - In-memory cache with 1-hour TTL
* - Concurrency-limited to 5 parallel requests
* - Silent failure: 404, timeout, rate-limit → entry skipped
*/
import https from 'node:https';
import { createLogger } from '@shared/utils/logger';
import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers';
const logger = createLogger('Extensions:GitHubStars');
// ── Constants ──────────────────────────────────────────────────────────────
const CACHE_TTL_MS = 60 * 60_000; // 1 hour
const HTTP_TIMEOUT_MS = 10_000; // 10 seconds
const MAX_CONCURRENCY = 5;
const MAX_BODY_SIZE = 256 * 1024; // 256KB (GitHub repo JSON is ~10KB)
// ── Cache entry ────────────────────────────────────────────────────────────
interface CacheEntry {
stars: number;
fetchedAt: number;
}
// ── Service ────────────────────────────────────────────────────────────────
export class GitHubStarsService {
private cache = new Map<string, CacheEntry>();
/**
* Fetch GitHub stars for a batch of repository URLs.
* Returns `Record<repositoryUrl, starCount>` — only includes URLs with valid results.
*/
async fetchStars(repositoryUrls: string[]): Promise<Record<string, number>> {
const result: Record<string, number> = {};
const tasks: Array<{ url: string; owner: string; repo: string }> = [];
for (const url of repositoryUrls) {
const parsed = parseGitHubOwnerRepo(url);
if (!parsed) continue;
const cacheKey = `${parsed.owner}/${parsed.repo}`.toLowerCase();
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
result[url] = cached.stars;
continue;
}
tasks.push({ url, owner: parsed.owner, repo: parsed.repo });
}
if (tasks.length === 0) return result;
// Fetch with concurrency limit
const outcomes = await this.withConcurrencyLimit(
tasks.map(({ url, owner, repo }) => async () => {
const stars = await this.fetchSingle(owner, repo);
if (stars != null) {
const cacheKey = `${owner}/${repo}`.toLowerCase();
this.cache.set(cacheKey, { stars, fetchedAt: Date.now() });
result[url] = stars;
}
}),
MAX_CONCURRENCY
);
// Log any failures for debugging (already silent to caller)
const failures = outcomes.filter((o) => o === 'error').length;
if (failures > 0) {
logger.debug(
`GitHub stars: ${tasks.length - failures}/${tasks.length} fetched, ${failures} failed`
);
}
return result;
}
/**
* Fetch stargazer count for a single repo. Returns null on any error.
*/
private async fetchSingle(owner: string, repo: string): Promise<number | null> {
try {
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const { statusCode, body } = await githubGet(url);
if (statusCode !== 200) {
if (statusCode === 403 || statusCode === 429) {
logger.debug(`GitHub API rate limit hit (${statusCode}), skipping remaining`);
}
return null;
}
const data = JSON.parse(body) as { stargazers_count?: number };
return typeof data.stargazers_count === 'number' ? data.stargazers_count : null;
} catch {
return null;
}
}
/**
* Run async tasks with a concurrency limit.
*/
private async withConcurrencyLimit(
tasks: Array<() => Promise<void>>,
limit: number
): Promise<Array<'ok' | 'error'>> {
const results: Array<'ok' | 'error'> = [];
let index = 0;
const run = async (): Promise<void> => {
while (index < tasks.length) {
const i = index++;
try {
await tasks[i]();
results[i] = 'ok';
} catch {
results[i] = 'error';
}
}
};
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => run());
await Promise.all(workers);
return results;
}
}
// ── HTTP helper (GitHub-specific) ──────────────────────────────────────────
function githubGet(url: string): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
let settled = false;
const settleResolve = (v: { statusCode: number; body: string }): void => {
if (!settled) {
settled = true;
resolve(v);
}
};
const settleReject = (e: Error): void => {
if (!settled) {
settled = true;
reject(e);
}
};
const req = https.get(
url,
{
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'claude-devtools',
},
},
(res) => {
const status = res.statusCode ?? 0;
const chunks: Buffer[] = [];
let totalSize = 0;
res.on('data', (c: Buffer) => {
totalSize += c.length;
if (totalSize > MAX_BODY_SIZE) {
res.destroy(new Error('Response too large'));
return;
}
chunks.push(c);
});
res.on('end', () =>
settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
);
res.on('error', settleReject);
}
);
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout: ${url}`)));
req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
});
}