|
| 1 | +import { FetchError, LightRepoResult } from './types' |
| 2 | + |
| 3 | +const GRAPHQL_URL = 'https://api.github.com/graphql' |
| 4 | + |
| 5 | +const REPO_QUERY = ` |
| 6 | + query($owner: String!, $name: String!) { |
| 7 | + repository(owner: $owner, name: $name) { |
| 8 | + description |
| 9 | + primaryLanguage { name } |
| 10 | + repositoryTopics(first: 25) { nodes { topic { name } } } |
| 11 | + stargazerCount |
| 12 | + forkCount |
| 13 | + watchers { totalCount } |
| 14 | + issues(states: OPEN) { totalCount } |
| 15 | + pushedAt |
| 16 | + isArchived |
| 17 | + isDisabled |
| 18 | + isFork |
| 19 | + createdAt |
| 20 | + } |
| 21 | + } |
| 22 | +` |
| 23 | + |
| 24 | +export function parseGithubUrl(url: string): { owner: string; name: string } { |
| 25 | + const match = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/) |
| 26 | + if (!match) throw new FetchError('MALFORMED', `Cannot parse GitHub URL: ${url}`) |
| 27 | + return { owner: match[1], name: match[2] } |
| 28 | +} |
| 29 | + |
| 30 | +export async function fetchLightRepo(url: string, token: string): Promise<LightRepoResult> { |
| 31 | + const { owner, name } = parseGithubUrl(url) |
| 32 | + |
| 33 | + let response: Response |
| 34 | + try { |
| 35 | + response = await fetch(GRAPHQL_URL, { |
| 36 | + method: 'POST', |
| 37 | + headers: { |
| 38 | + Authorization: `bearer ${token}`, |
| 39 | + 'Content-Type': 'application/json', |
| 40 | + }, |
| 41 | + body: JSON.stringify({ query: REPO_QUERY, variables: { owner, name } }), |
| 42 | + }) |
| 43 | + } catch (err) { |
| 44 | + throw new FetchError('TRANSIENT', `Network error for ${url}: ${(err as Error).message}`) |
| 45 | + } |
| 46 | + |
| 47 | + const resetSec = parseInt(response.headers.get('x-ratelimit-reset') ?? '0', 10) |
| 48 | + const resetMs = resetSec ? resetSec * 1000 + 5_000 : Date.now() + 65_000 |
| 49 | + |
| 50 | + if (response.status === 401) { |
| 51 | + throw new FetchError('AUTH', `401 Unauthorized for ${url}`) |
| 52 | + } |
| 53 | + |
| 54 | + if (response.status === 403) { |
| 55 | + const body = await response.text() |
| 56 | + if (body.toLowerCase().includes('rate limit')) { |
| 57 | + throw new FetchError('RATE_LIMIT', `Rate limited on ${url}`, resetMs) |
| 58 | + } |
| 59 | + throw new FetchError('AUTH', `403 Forbidden for ${url}`) |
| 60 | + } |
| 61 | + |
| 62 | + if (response.status === 404) throw new FetchError('NOT_FOUND', `404 for ${url}`) |
| 63 | + if (response.status >= 500) throw new FetchError('TRANSIENT', `${response.status} for ${url}`) |
| 64 | + |
| 65 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 66 | + const json = (await response.json()) as any |
| 67 | + |
| 68 | + if (json.errors?.length) { |
| 69 | + const err = json.errors[0] |
| 70 | + if (err.type === 'RATE_LIMITED') throw new FetchError('RATE_LIMIT', `RATE_LIMITED for ${url}`, resetMs) |
| 71 | + if (err.type === 'NOT_FOUND') throw new FetchError('NOT_FOUND', `NOT_FOUND for ${url}`) |
| 72 | + throw new FetchError('TRANSIENT', `GraphQL error for ${url}: ${err.message ?? err.type}`) |
| 73 | + } |
| 74 | + |
| 75 | + const repo = json.data?.repository |
| 76 | + if (!repo) throw new FetchError('NOT_FOUND', `No repository data for ${url}`) |
| 77 | + |
| 78 | + return { |
| 79 | + url, |
| 80 | + host: 'github', |
| 81 | + owner, |
| 82 | + name, |
| 83 | + description: repo.description ?? null, |
| 84 | + primaryLanguage: repo.primaryLanguage?.name ?? null, |
| 85 | + topics: (repo.repositoryTopics?.nodes ?? []).map((n: { topic: { name: string } }) => n.topic.name), |
| 86 | + stars: repo.stargazerCount ?? 0, |
| 87 | + forks: repo.forkCount ?? 0, |
| 88 | + watchers: repo.watchers?.totalCount ?? 0, |
| 89 | + openIssues: repo.issues?.totalCount ?? 0, |
| 90 | + lastCommitAt: repo.pushedAt ?? null, |
| 91 | + archived: repo.isArchived ?? false, |
| 92 | + disabled: repo.isDisabled ?? false, |
| 93 | + isFork: repo.isFork ?? false, |
| 94 | + createdAt: repo.createdAt ?? null, |
| 95 | + } |
| 96 | +} |
0 commit comments