Skip to content

Commit 59d1ec0

Browse files
committed
feat: add enricher helper files (types, fetchLightRepo, updateEnrichedRepos)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 3d266dc commit 59d1ec0

3 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export interface LightRepoResult {
2+
url: string
3+
host: 'github'
4+
owner: string
5+
name: string
6+
description: string | null
7+
primaryLanguage: string | null
8+
topics: string[]
9+
stars: number
10+
forks: number
11+
watchers: number
12+
openIssues: number
13+
lastCommitAt: string | null
14+
archived: boolean
15+
disabled: boolean
16+
isFork: boolean
17+
createdAt: string | null
18+
}
19+
20+
export type FetchErrorKind = 'RATE_LIMIT' | 'TRANSIENT' | 'NOT_FOUND' | 'AUTH' | 'MALFORMED'
21+
22+
export class FetchError extends Error {
23+
constructor(
24+
public readonly kind: FetchErrorKind,
25+
message: string,
26+
public readonly resetAt?: number,
27+
) {
28+
super(message)
29+
this.name = 'FetchError'
30+
}
31+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { getServiceChildLogger } from '@crowd/logging'
2+
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
3+
4+
import { LightRepoResult } from './types'
5+
6+
const log = getServiceChildLogger('github-repos-enricher:update')
7+
8+
export async function updateEnrichedRepos(qx: QueryExecutor, rows: LightRepoResult[]): Promise<void> {
9+
if (rows.length === 0) return
10+
11+
for (const r of rows) {
12+
await qx.result(
13+
`UPDATE repos SET
14+
host = COALESCE(host, $(host)),
15+
owner = COALESCE(owner, $(owner)),
16+
name = COALESCE(name, $(name)),
17+
description = $(description),
18+
primary_language = $(primaryLanguage),
19+
topics = $(topics)::text[],
20+
stars = $(stars),
21+
forks = $(forks),
22+
watchers = $(watchers),
23+
open_issues = $(openIssues),
24+
last_commit_at = $(lastCommitAt)::timestamptz,
25+
archived = $(archived),
26+
disabled = $(disabled),
27+
is_fork = $(isFork),
28+
created_at = COALESCE(created_at, $(createdAt)::timestamptz),
29+
last_synced_at = NOW()
30+
WHERE url = $(url)`,
31+
r,
32+
)
33+
}
34+
35+
log.debug({ count: rows.length }, 'Updated enriched repos')
36+
}

0 commit comments

Comments
 (0)