Skip to content

Commit ad74981

Browse files
authored
feat: repo known files tracking [CM-1329] (#4396)
Signed-off-by: Mouad BANI <mouad-mb@outlook.com>
1 parent c78b133 commit ad74981

6 files changed

Lines changed: 233 additions & 71 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- Well-known files inventory per repo, populated by the github-repos-enricher.
2+
CREATE TABLE IF NOT EXISTS repo_well_known_files (
3+
id bigserial PRIMARY KEY,
4+
repo_id bigint NOT NULL REFERENCES repos (id) ON DELETE CASCADE,
5+
file_type text NOT NULL, -- security | contributing | governance | maintainers | code_of_conduct | readme
6+
directory text NOT NULL, -- root | .github | docs
7+
path text NOT NULL,
8+
blob_oid text NOT NULL,
9+
first_seen_at timestamptz NOT NULL DEFAULT NOW(),
10+
-- observation time, not commit time; bumped on content change, disappearance, reappearance
11+
change_detected_at timestamptz NOT NULL DEFAULT NOW(),
12+
checked_at timestamptz NOT NULL,
13+
deleted_at timestamptz,
14+
UNIQUE (repo_id, path)
15+
);
16+
17+
CREATE INDEX IF NOT EXISTS repo_well_known_files_change_detected_at_idx
18+
ON repo_well_known_files (change_detected_at);
19+
20+
CREATE INDEX IF NOT EXISTS repo_well_known_files_file_type_idx
21+
ON repo_well_known_files (file_type)
22+
WHERE deleted_at IS NULL;

services/apps/packages_worker/src/enricher/fetchLightRepo.ts

Lines changed: 27 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { getServiceChildLogger } from '@crowd/logging'
22

33
import { FetchError, LightRepoResult } from './types'
4+
import {
5+
RepoTrees,
6+
TreeEntryNode,
7+
classifyWellKnownFiles,
8+
deriveSecurityFileEnabled,
9+
} from './wellKnownFiles'
410

511
const log = getServiceChildLogger('fetch-light-repo')
612

@@ -24,6 +30,15 @@ const REPO_QUERY = `
2430
createdAt
2531
isSecurityPolicyEnabled
2632
defaultBranchRef { name }
33+
rootTree: object(expression: "HEAD:") {
34+
... on Tree { entries { name type oid } }
35+
}
36+
githubTree: object(expression: "HEAD:.github") {
37+
... on Tree { entries { name type oid } }
38+
}
39+
docsTree: object(expression: "HEAD:docs") {
40+
... on Tree { entries { name type oid } }
41+
}
2742
}
2843
}
2944
`
@@ -34,67 +49,6 @@ export function parseGithubUrl(url: string): { owner: string; name: string } {
3449
return { owner: match[1], name: match[2] }
3550
}
3651

37-
// community/profile API doesn't reliably return files.security — use Contents API instead.
38-
async function fetchSecurityFileEnabled(
39-
url: string,
40-
owner: string,
41-
name: string,
42-
token: string,
43-
timeoutMs: number,
44-
): Promise<boolean | null> {
45-
const headers = { Authorization: `bearer ${token}`, Accept: 'application/vnd.github+json' }
46-
const check = async (path: string): Promise<boolean> => {
47-
const controller = new AbortController()
48-
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
49-
try {
50-
const response = await fetch(`${GITHUB_API_URL}/repos/${owner}/${name}/contents/${path}`, {
51-
headers,
52-
signal: controller.signal,
53-
})
54-
if (response.status === 200) return true
55-
if (response.status === 404) return false
56-
if (response.status === 403) {
57-
const body = await response.text()
58-
if (body.toLowerCase().includes('rate limit')) {
59-
// REST secondary limits send retry-after; primary limits send x-ratelimit-reset
60-
const retryAfterSec = parseInt(response.headers.get('retry-after') ?? '0', 10)
61-
const resetSec = parseInt(response.headers.get('x-ratelimit-reset') ?? '0', 10)
62-
const resetMs = retryAfterSec
63-
? Date.now() + retryAfterSec * 1000
64-
: resetSec
65-
? resetSec * 1000 + 5_000
66-
: Date.now() + 65_000
67-
throw new FetchError('RATE_LIMIT', `Contents API rate limited on ${path}`, resetMs)
68-
}
69-
}
70-
throw new Error(`Unexpected status ${response.status} for ${path}`)
71-
} finally {
72-
clearTimeout(timeoutId)
73-
}
74-
}
75-
76-
try {
77-
const [root, dotGithub] = await Promise.all([
78-
check('SECURITY.md'),
79-
check('.github/SECURITY.md'),
80-
])
81-
return root || dotGithub
82-
} catch (err) {
83-
// Rate limits propagate so the caller can park the installation and requeue the repo
84-
if (err instanceof FetchError && err.kind === 'RATE_LIMIT') throw err
85-
log.warn(
86-
{
87-
url,
88-
errName: (err as Error).name,
89-
errMsg: (err as Error).message,
90-
errStack: (err as Error).stack,
91-
},
92-
'Security file check failed — securityFileEnabled will be null',
93-
)
94-
return null
95-
}
96-
}
97-
9852
interface BranchProtection {
9953
enabled: boolean | null
10054
requiredReviews: number | null
@@ -214,6 +168,9 @@ interface RepoGraphqlResponse {
214168
createdAt: string
215169
isSecurityPolicyEnabled: boolean
216170
defaultBranchRef: { name: string } | null
171+
rootTree: { entries?: TreeEntryNode[] } | null
172+
githubTree: { entries?: TreeEntryNode[] } | null
173+
docsTree: { entries?: TreeEntryNode[] } | null
217174
} | null
218175
}
219176
errors?: Array<{ type?: string; message?: string }>
@@ -262,10 +219,7 @@ export async function fetchLightRepo(
262219
if (response.status === 404) throw new FetchError('NOT_FOUND', `404 for ${url}`)
263220
if (response.status >= 500) throw new FetchError('TRANSIENT', `${response.status} for ${url}`)
264221

265-
const [json, securityFileEnabled] = await Promise.all([
266-
response.json() as Promise<RepoGraphqlResponse>,
267-
fetchSecurityFileEnabled(url, owner, name, token, timeoutMs),
268-
])
222+
const json = (await response.json()) as RepoGraphqlResponse
269223

270224
if (json.errors?.length) {
271225
const err = json.errors[0]
@@ -280,6 +234,12 @@ export async function fetchLightRepo(
280234
const repo = json.data?.repository
281235
if (!repo) throw new FetchError('NOT_FOUND', `No repository data for ${url}`)
282236

237+
const trees: RepoTrees = {
238+
root: repo.rootTree?.entries ?? null,
239+
github: repo.githubTree?.entries ?? null,
240+
docs: repo.docsTree?.entries ?? null,
241+
}
242+
283243
const defaultBranch = repo.defaultBranchRef?.name ?? null
284244
const branchProtection = defaultBranch
285245
? await fetchBranchProtection(url, owner, name, defaultBranch, token, timeoutMs)
@@ -305,7 +265,8 @@ export async function fetchLightRepo(
305265
isFork: repo.isFork ?? null,
306266
createdAt: repo.createdAt ?? null,
307267
securityPolicyEnabled: repo.isSecurityPolicyEnabled ?? null,
308-
securityFileEnabled,
268+
securityFileEnabled: deriveSecurityFileEnabled(trees),
269+
wellKnownFiles: classifyWellKnownFiles(trees),
309270
branchProtectionEnabled: branchProtection.enabled,
310271
branchProtectionRequiredReviews: branchProtection.requiredReviews,
311272
branchProtectionRequiresStatusChecks: branchProtection.requiresStatusChecks,

services/apps/packages_worker/src/enricher/runEnrichmentLoop.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ import { fetchActivitySnapshot } from './fetchActivitySnapshot'
77
import { fetchLightRepo, parseGithubUrl } from './fetchLightRepo'
88
import { GithubAppConfig, getInstallationToken } from './githubAppAuth'
99
import { InstallationPool } from './installationPool'
10-
import { FetchError, LightRepoResult, RepoActivitySnapshot } from './types'
10+
import {
11+
FetchError,
12+
LightRepoResult,
13+
RepoActivitySnapshot,
14+
RepoWellKnownFilesUpdate,
15+
} from './types'
1116
import { bulkUpdateEnrichedRepos, markReposSkipped } from './updateEnrichedRepos'
1217
import { bulkUpsertRepoActivitySnapshot } from './updateRepoActivitySnapshot'
18+
import { bulkUpsertRepoWellKnownFiles } from './updateWellKnownFiles'
1319

1420
const log = getServiceChildLogger('github-repos-enricher')
1521

@@ -67,6 +73,7 @@ class WriteBuffer {
6773
private results: LightRepoResult[] = []
6874
private snapshots: RepoActivitySnapshot[] = []
6975
private skipUrls: string[] = []
76+
private wellKnownFiles: RepoWellKnownFilesUpdate[] = []
7077
private lastFlushAt = Date.now()
7178
private flushing = false
7279
private flushFailures = 0
@@ -81,6 +88,10 @@ class WriteBuffer {
8188
this.snapshots.push(snapshot)
8289
}
8390

91+
addWellKnownFiles(update: RepoWellKnownFilesUpdate): void {
92+
this.wellKnownFiles.push(update)
93+
}
94+
8495
addSkip(url: string): void {
8596
this.skipUrls.push(url)
8697
}
@@ -94,22 +105,34 @@ class WriteBuffer {
94105
)
95106
}
96107

97-
private clearBatch(resultCount: number, snapshotCount: number, skipCount: number): void {
108+
private clearBatch(
109+
resultCount: number,
110+
snapshotCount: number,
111+
skipCount: number,
112+
wellKnownFilesCount: number,
113+
): void {
98114
this.results.splice(0, resultCount)
99115
this.snapshots.splice(0, snapshotCount)
100116
this.skipUrls.splice(0, skipCount)
117+
this.wellKnownFiles.splice(0, wellKnownFilesCount)
101118
this.flushFailures = 0
102119
}
103120

104121
async flush(): Promise<number> {
105122
this.lastFlushAt = Date.now()
106-
if (this.results.length === 0 && this.snapshots.length === 0 && this.skipUrls.length === 0) {
123+
if (
124+
this.results.length === 0 &&
125+
this.snapshots.length === 0 &&
126+
this.skipUrls.length === 0 &&
127+
this.wellKnownFiles.length === 0
128+
) {
107129
return 0
108130
}
109131

110132
const batch = [...this.results]
111133
const snapshotBatch = [...this.snapshots]
112134
const skips = [...this.skipUrls]
135+
const wellKnownFilesBatch = [...this.wellKnownFiles]
113136
this.flushing = true
114137
try {
115138
// The snapshot upsert also updates repos rows — run in one transaction to avoid
@@ -118,8 +141,9 @@ class WriteBuffer {
118141
await bulkUpdateEnrichedRepos(tx, batch)
119142
await markReposSkipped(tx, skips)
120143
await bulkUpsertRepoActivitySnapshot(tx, snapshotBatch)
144+
await bulkUpsertRepoWellKnownFiles(tx, wellKnownFilesBatch)
121145
})
122-
this.clearBatch(batch.length, snapshotBatch.length, skips.length)
146+
this.clearBatch(batch.length, snapshotBatch.length, skips.length, wellKnownFilesBatch.length)
123147
return batch.length
124148
} catch (err) {
125149
this.flushFailures++
@@ -139,7 +163,13 @@ class WriteBuffer {
139163
? 'Flush failed repeatedly — dropping batch, repos will be re-enriched next sweep'
140164
: 'Flush failed — will retry on next cycle',
141165
)
142-
if (dropBatch) this.clearBatch(batch.length, snapshotBatch.length, skips.length)
166+
if (dropBatch)
167+
this.clearBatch(
168+
batch.length,
169+
snapshotBatch.length,
170+
skips.length,
171+
wellKnownFilesBatch.length,
172+
)
143173
return 0
144174
} finally {
145175
this.flushing = false
@@ -360,6 +390,11 @@ async function processRepo(row: RepoRow, ctx: WorkerContext): Promise<void> {
360390
if (outcome.kind === 'success') {
361391
metrics.totalFetched++
362392
writeBuffer.add(outcome.data)
393+
writeBuffer.addWellKnownFiles({
394+
repoId: row.id,
395+
checkedAt: new Date().toISOString(),
396+
files: outcome.data.wellKnownFiles,
397+
})
363398
pool.parkIfBudgetLow(
364399
installationId,
365400
outcome.data.rateLimit?.remaining,

services/apps/packages_worker/src/enricher/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { WellKnownFileEntry } from './wellKnownFiles'
2+
13
export interface LightRepoResult {
24
url: string
35
host: 'github'
@@ -17,6 +19,7 @@ export interface LightRepoResult {
1719
createdAt: string | null
1820
securityPolicyEnabled: boolean | null
1921
securityFileEnabled: boolean | null
22+
wellKnownFiles: WellKnownFileEntry[]
2023
branchProtectionEnabled: boolean | null
2124
branchProtectionRequiredReviews: number | null
2225
branchProtectionRequiresStatusChecks: boolean | null
@@ -29,6 +32,12 @@ export interface LightRepoResult {
2932
} | null
3033
}
3134

35+
export interface RepoWellKnownFilesUpdate {
36+
repoId: string
37+
checkedAt: string
38+
files: WellKnownFileEntry[]
39+
}
40+
3241
export interface RepoActivitySnapshot {
3342
repoId: string
3443
snapshotAt: string
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
2+
3+
import { RepoWellKnownFilesUpdate } from './types'
4+
5+
export async function bulkUpsertRepoWellKnownFiles(
6+
qx: QueryExecutor,
7+
updates: RepoWellKnownFilesUpdate[],
8+
): Promise<void> {
9+
if (updates.length === 0) return
10+
11+
// dedupe by repoId: ON CONFLICT DO UPDATE cannot affect the same row twice in one statement
12+
const byRepo = new Map(updates.map((u) => [u.repoId, u]))
13+
const batch = [...byRepo.values()]
14+
15+
await qx.result(
16+
`
17+
WITH input AS (
18+
SELECT
19+
(j->>'repoId')::bigint AS repo_id,
20+
(j->>'checkedAt')::timestamptz AS checked_at,
21+
j->'files' AS files
22+
FROM jsonb_array_elements($1::jsonb) j
23+
),
24+
found AS (
25+
SELECT
26+
i.repo_id,
27+
f->>'fileType' AS file_type,
28+
f->>'directory' AS directory,
29+
f->>'path' AS path,
30+
f->>'blobOid' AS blob_oid,
31+
i.checked_at
32+
FROM input i, jsonb_array_elements(i.files) f
33+
),
34+
soft_deleted AS (
35+
UPDATE repo_well_known_files w
36+
SET deleted_at = i.checked_at,
37+
change_detected_at = i.checked_at
38+
FROM input i
39+
WHERE w.repo_id = i.repo_id
40+
AND w.deleted_at IS NULL
41+
AND NOT EXISTS (
42+
SELECT 1 FROM found f WHERE f.repo_id = w.repo_id AND f.path = w.path
43+
)
44+
)
45+
INSERT INTO repo_well_known_files (repo_id, file_type, directory, path, blob_oid, checked_at, change_detected_at)
46+
SELECT repo_id, file_type, directory, path, blob_oid, checked_at, checked_at
47+
FROM found
48+
ON CONFLICT (repo_id, path) DO UPDATE SET
49+
blob_oid = EXCLUDED.blob_oid,
50+
file_type = EXCLUDED.file_type,
51+
directory = EXCLUDED.directory,
52+
checked_at = EXCLUDED.checked_at,
53+
deleted_at = NULL,
54+
change_detected_at = CASE
55+
WHEN repo_well_known_files.blob_oid <> EXCLUDED.blob_oid
56+
OR repo_well_known_files.deleted_at IS NOT NULL
57+
THEN EXCLUDED.checked_at
58+
ELSE repo_well_known_files.change_detected_at
59+
END
60+
`,
61+
[JSON.stringify(batch)],
62+
)
63+
}

0 commit comments

Comments
 (0)