Skip to content

Commit afbdbdd

Browse files
committed
feat: add enrichment loop and github-repos-enricher entry point
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent 57066a5 commit afbdbdd

7 files changed

Lines changed: 720 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
4+
import { getServiceLogger } from '@crowd/logging'
5+
6+
import { getEnricherConfig } from '../config'
7+
import { getPackagesDb } from '../db'
8+
import { runEnrichmentLoop } from '../enricher/runEnrichmentLoop'
9+
10+
const log = getServiceLogger()
11+
12+
const liveFilePath = path.join(__dirname, '../tmp/github-repos-enricher-live.tmp')
13+
const readyFilePath = path.join(__dirname, '../tmp/github-repos-enricher-ready.tmp')
14+
15+
let shuttingDown = false
16+
17+
const shutdown = async () => {
18+
if (shuttingDown) return
19+
shuttingDown = true
20+
log.info('Shutting down github-repos-enricher...')
21+
}
22+
23+
process.on('SIGINT', shutdown)
24+
process.on('SIGTERM', shutdown)
25+
26+
const main = async () => {
27+
log.info('github-repos-enricher starting...')
28+
29+
const config = getEnricherConfig()
30+
31+
if (config.tokens.length === 0) {
32+
log.error('ENRICHER_GITHUB_TOKENS is required (comma-separated PATs)')
33+
process.exit(1)
34+
}
35+
36+
const qx = await getPackagesDb()
37+
await qx.selectOne('SELECT 1')
38+
log.info('Connected to packages-db.')
39+
40+
fs.mkdirSync(path.dirname(liveFilePath), { recursive: true })
41+
42+
const healthInterval = setInterval(async () => {
43+
if (shuttingDown) return
44+
try {
45+
await Promise.all([
46+
fs.promises.open(liveFilePath, 'a').then((f) => f.close()),
47+
fs.promises.open(readyFilePath, 'a').then((f) => f.close()),
48+
])
49+
} catch (err) {
50+
log.warn({ err }, 'Failed to write health probe files')
51+
}
52+
}, 5000)
53+
54+
log.info(
55+
{ tokens: config.tokens.length, pageSize: config.pageSize, batchSize: config.batchSize },
56+
'Starting enrichment loop',
57+
)
58+
59+
await runEnrichmentLoop(qx, config, () => shuttingDown)
60+
61+
clearInterval(healthInterval)
62+
log.info('github-repos-enricher stopped.')
63+
process.exit(0)
64+
}
65+
66+
main().catch((err) => {
67+
log.error({ err }, 'github-repos-enricher fatal error')
68+
process.exit(1)
69+
})
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { getServiceChildLogger } from '@crowd/logging'
2+
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
3+
4+
import { getEnricherConfig } from '../config'
5+
import { fetchLightRepo, parseGithubUrl } from './fetchLightRepo'
6+
import { FetchError, LightRepoResult } from './types'
7+
import { updateEnrichedRepos } from './updateEnrichedRepos'
8+
9+
const log = getServiceChildLogger('github-repos-enricher')
10+
11+
async function fetchWithRetries(
12+
url: string,
13+
token: string,
14+
maxRetries: number,
15+
): Promise<LightRepoResult | null> {
16+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
17+
try {
18+
return await fetchLightRepo(url, token)
19+
} catch (err) {
20+
if (!(err instanceof FetchError)) throw err
21+
22+
if (['NOT_FOUND', 'AUTH', 'MALFORMED'].includes(err.kind)) {
23+
log.warn({ url, kind: err.kind }, err.message)
24+
return null
25+
}
26+
27+
if (err.kind === 'RATE_LIMIT') throw err
28+
29+
if (attempt < maxRetries) {
30+
const backoffMs = 1000 * 2 ** attempt
31+
log.warn({ url, attempt, backoffMs }, `Transient error, retrying: ${err.message}`)
32+
await new Promise((r) => setTimeout(r, backoffMs))
33+
} else {
34+
log.error({ url }, `Gave up after ${maxRetries} retries: ${err.message}`)
35+
return null
36+
}
37+
}
38+
}
39+
return null
40+
}
41+
42+
async function fetchPage(
43+
qx: QueryExecutor,
44+
cursor: string | null,
45+
pageSize: number,
46+
updateIntervalHours: number,
47+
): Promise<{ rows: Array<{ id: string; url: string }>; urls: string[] }> {
48+
const rows = await qx.select(
49+
`
50+
SELECT id, url
51+
FROM repos
52+
WHERE host = 'github'
53+
AND (last_synced_at IS NULL OR last_synced_at < NOW() - INTERVAL '$(updateIntervalHours) hours')
54+
AND ($(cursor) IS NULL OR id > $(cursor))
55+
ORDER BY id
56+
LIMIT $(pageSize)
57+
`,
58+
{ cursor, pageSize, updateIntervalHours },
59+
)
60+
return {
61+
rows,
62+
urls: rows.map((r: { url: string }) => r.url),
63+
}
64+
}
65+
66+
async function processPage(
67+
urls: string[],
68+
tokens: string[],
69+
parkedUntil: Map<string, number>,
70+
config: ReturnType<typeof getEnricherConfig>,
71+
qx: QueryExecutor,
72+
): Promise<{ fetched: number; failed: number; flushed: number }> {
73+
const validUrls: string[] = []
74+
let skipped = 0
75+
for (const url of urls) {
76+
try {
77+
parseGithubUrl(url)
78+
validUrls.push(url)
79+
} catch {
80+
skipped++
81+
}
82+
}
83+
if (skipped > 0) log.warn(`Skipped ${skipped} non-GitHub URLs`)
84+
85+
const buffer: LightRepoResult[] = []
86+
const failures: Array<{ url: string; reason: string }> = []
87+
let failed = 0
88+
let flushed = 0
89+
let nextIdx = 0
90+
91+
await Promise.all(
92+
tokens.map(async (token, tokenIdx) => {
93+
// Respect any park set during a previous page of this run
94+
const initialPark = (parkedUntil.get(token) ?? 0) - Date.now()
95+
if (initialPark > 0) {
96+
log.warn(`token#${tokenIdx} still parked, waiting ${Math.round(initialPark / 1000)}s`)
97+
await new Promise((r) => setTimeout(r, initialPark))
98+
}
99+
100+
while (true) {
101+
const idx = nextIdx++
102+
if (idx >= validUrls.length) break
103+
const url = validUrls[idx]
104+
105+
try {
106+
const result = await fetchWithRetries(url, token, config.maxRetries)
107+
if (result) {
108+
buffer.push(result)
109+
if (buffer.length >= config.batchSize) {
110+
const batch = buffer.splice(0)
111+
await updateEnrichedRepos(qx, batch)
112+
flushed += batch.length
113+
}
114+
} else {
115+
failures.push({ url, reason: 'see warn log above' })
116+
failed++
117+
}
118+
} catch (err) {
119+
if (err instanceof FetchError && err.kind === 'RATE_LIMIT') {
120+
const resetAt = err.resetAt ?? Date.now() + 60_000
121+
const waitMs = Math.max(1_000, resetAt - Date.now())
122+
parkedUntil.set(token, resetAt)
123+
log.warn(
124+
{ tokenIdx, parkedUntil: new Date(resetAt).toISOString() },
125+
`token#${tokenIdx} rate limited — parking for ${Math.round(waitMs / 1000)}s`,
126+
)
127+
await new Promise((r) => setTimeout(r, waitMs))
128+
failures.push({ url, reason: 'rate-limit' })
129+
failed++
130+
} else {
131+
log.error({ url, err }, 'Unexpected error')
132+
failures.push({ url, reason: (err as Error).message })
133+
failed++
134+
}
135+
}
136+
}
137+
}),
138+
)
139+
140+
if (buffer.length > 0) {
141+
await updateEnrichedRepos(qx, buffer)
142+
flushed += buffer.length
143+
}
144+
145+
if (failures.length > 0) {
146+
log.warn({ failures }, `${failures.length} repo(s) failed this page`)
147+
}
148+
149+
return { fetched: validUrls.length - failed, failed, flushed }
150+
}
151+
152+
export async function runEnrichmentLoop(
153+
qx: QueryExecutor,
154+
config: ReturnType<typeof getEnricherConfig>,
155+
isShuttingDown: () => boolean,
156+
): Promise<void> {
157+
const parkedUntil = new Map<string, number>()
158+
let cursor: string | null = null
159+
let pageNum = 0
160+
161+
while (!isShuttingDown()) {
162+
pageNum++
163+
164+
const { rows, urls } = await fetchPage(qx, cursor, config.pageSize, config.updateIntervalHours)
165+
166+
if (urls.length === 0) {
167+
log.info('No more repos to process, sleeping')
168+
await new Promise((r) => setTimeout(r, config.idleSleepSec * 1000))
169+
cursor = null
170+
continue
171+
}
172+
173+
const { fetched, failed, flushed } = await processPage(urls, config.tokens, parkedUntil, config, qx)
174+
175+
log.info(
176+
`Page ${pageNum}: read=${urls.length} fetched=${fetched} failed=${failed} flushed=${flushed}`,
177+
)
178+
179+
if (rows.length > 0) {
180+
cursor = rows[rows.length - 1].id
181+
}
182+
}
183+
}

services/apps/script_executor_worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"recalculate-enrichment-affiliations": "npx tsx src/bin/recalculate-enrichment-affiliations.ts",
1313
"recalculate-all-affiliations": "npx tsx src/bin/recalculate-all-affiliations.ts",
1414
"add-lf-projects-to-collection": "npx tsx src/bin/add-lf-projects-to-collection.ts",
15+
"sync-light-repos": "npx tsx src/bin/sync-light-repos/index.ts",
1516
"lint": "npx eslint --ext .ts src --max-warnings=0",
1617
"format": "npx prettier --write \"src/**/*.ts\"",
1718
"format-check": "npx prettier --check .",
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+
}

0 commit comments

Comments
 (0)