|
| 1 | +import { getServiceChildLogger } from '@crowd/logging' |
| 2 | + |
| 3 | +import { parseGithubUrl } from '../../enricher/fetchLightRepo' |
| 4 | +import { ExtractorDeps, ProvenanceEntry, RawContact, RepoTarget } from '../types' |
| 5 | + |
| 6 | +import { isEmail } from './http' |
| 7 | + |
| 8 | +const log = getServiceChildLogger('security-contacts:top-committers') |
| 9 | + |
| 10 | +const SOURCE = 'github-commits' |
| 11 | +const SINCE_DAYS = 90 |
| 12 | +const TOP_N = 3 |
| 13 | +const PER_PAGE = 100 |
| 14 | +const MAX_PAGES = 3 |
| 15 | + |
| 16 | +interface CommitAuthor { |
| 17 | + email?: unknown |
| 18 | + name?: unknown |
| 19 | + date?: unknown |
| 20 | +} |
| 21 | + |
| 22 | +interface CommitEntry { |
| 23 | + commit?: { author?: CommitAuthor } |
| 24 | + author?: { login?: unknown; type?: unknown } | null |
| 25 | +} |
| 26 | + |
| 27 | +interface AggregatedAuthor { |
| 28 | + email: string |
| 29 | + name?: string |
| 30 | + login?: string |
| 31 | + count: number |
| 32 | + latestDate: string |
| 33 | +} |
| 34 | + |
| 35 | +const BOT_TOKENS = ['dependabot', 'renovate', 'github-actions'] |
| 36 | + |
| 37 | +export function isBotCommit(c: CommitEntry): boolean { |
| 38 | + if (c.author?.type === 'Bot') return true |
| 39 | + const email = typeof c.commit?.author?.email === 'string' ? c.commit.author.email : '' |
| 40 | + const name = typeof c.commit?.author?.name === 'string' ? c.commit.author.name : '' |
| 41 | + const login = typeof c.author?.login === 'string' ? c.author.login : '' |
| 42 | + if (/\[bot\]@/i.test(email)) return true |
| 43 | + const haystack = `${email} ${name} ${login}`.toLowerCase() |
| 44 | + return BOT_TOKENS.some((token) => haystack.includes(token)) |
| 45 | +} |
| 46 | + |
| 47 | +export function aggregateTopCommitters( |
| 48 | + commits: CommitEntry[], |
| 49 | + topN: number = TOP_N, |
| 50 | +): AggregatedAuthor[] { |
| 51 | + const byEmail = new Map<string, AggregatedAuthor>() |
| 52 | + |
| 53 | + for (const c of commits) { |
| 54 | + if (isBotCommit(c)) continue |
| 55 | + |
| 56 | + const rawEmail = c.commit?.author?.email |
| 57 | + if (typeof rawEmail !== 'string' || !isEmail(rawEmail)) continue |
| 58 | + const email = rawEmail.toLowerCase() |
| 59 | + |
| 60 | + const rawName = c.commit?.author?.name |
| 61 | + const name = typeof rawName === 'string' ? rawName : undefined |
| 62 | + const rawDate = c.commit?.author?.date |
| 63 | + const date = typeof rawDate === 'string' ? rawDate : undefined |
| 64 | + const rawLogin = c.author?.login |
| 65 | + const login = typeof rawLogin === 'string' ? rawLogin : undefined |
| 66 | + |
| 67 | + const existing = byEmail.get(email) |
| 68 | + if (existing) { |
| 69 | + existing.count += 1 |
| 70 | + if (!existing.login && login) existing.login = login |
| 71 | + if (date && date > existing.latestDate) existing.latestDate = date |
| 72 | + } else { |
| 73 | + byEmail.set(email, { |
| 74 | + email, |
| 75 | + name, |
| 76 | + login, |
| 77 | + count: 1, |
| 78 | + latestDate: date ?? new Date(0).toISOString(), |
| 79 | + }) |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + return [...byEmail.values()] |
| 84 | + .sort((a, b) => b.count - a.count || a.email.localeCompare(b.email)) |
| 85 | + .slice(0, topN) |
| 86 | +} |
| 87 | + |
| 88 | +export function mapCommittersToContacts( |
| 89 | + authors: AggregatedAuthor[], |
| 90 | + fetchedAt: string, |
| 91 | + apiPath: string, |
| 92 | +): RawContact[] { |
| 93 | + const contacts: RawContact[] = [] |
| 94 | + |
| 95 | + for (const author of authors) { |
| 96 | + const provenance: ProvenanceEntry[] = [ |
| 97 | + { |
| 98 | + source: SOURCE, |
| 99 | + sourceTier: 'D', |
| 100 | + path: apiPath, |
| 101 | + fetchedAt, |
| 102 | + declaredAt: author.latestDate, |
| 103 | + }, |
| 104 | + ] |
| 105 | + |
| 106 | + contacts.push({ |
| 107 | + channel: 'email', |
| 108 | + value: author.email, |
| 109 | + name: author.name, |
| 110 | + handle: author.login, |
| 111 | + role: 'committer', |
| 112 | + tier: 'D', |
| 113 | + provenance, |
| 114 | + }) |
| 115 | + |
| 116 | + if (author.login) { |
| 117 | + contacts.push({ |
| 118 | + channel: 'github-handle', |
| 119 | + value: author.login, |
| 120 | + handle: author.login, |
| 121 | + role: 'committer', |
| 122 | + tier: 'D', |
| 123 | + provenance, |
| 124 | + }) |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + return contacts |
| 129 | +} |
| 130 | + |
| 131 | +async function fetchCommitPages( |
| 132 | + owner: string, |
| 133 | + name: string, |
| 134 | + since: string, |
| 135 | + deps: Pick<ExtractorDeps, 'githubGet'>, |
| 136 | +): Promise<CommitEntry[]> { |
| 137 | + const commits: CommitEntry[] = [] |
| 138 | + |
| 139 | + for (let page = 1; page <= MAX_PAGES; page++) { |
| 140 | + const path = `/repos/${owner}/${name}/commits?since=${since}&per_page=${PER_PAGE}&page=${page}` |
| 141 | + const { text } = await deps.githubGet(path) |
| 142 | + if (!text) break |
| 143 | + |
| 144 | + const entries = JSON.parse(text) as CommitEntry[] |
| 145 | + if (!Array.isArray(entries) || entries.length === 0) break |
| 146 | + |
| 147 | + commits.push(...entries) |
| 148 | + if (entries.length < PER_PAGE) break |
| 149 | + } |
| 150 | + |
| 151 | + return commits |
| 152 | +} |
| 153 | + |
| 154 | +export async function fetchTopCommitters( |
| 155 | + target: RepoTarget, |
| 156 | + deps: Pick<ExtractorDeps, 'githubGet'>, |
| 157 | + now: () => Date = () => new Date(), |
| 158 | +): Promise<RawContact[]> { |
| 159 | + let owner: string |
| 160 | + let name: string |
| 161 | + try { |
| 162 | + ;({ owner, name } = parseGithubUrl(target.url)) |
| 163 | + } catch { |
| 164 | + return [] |
| 165 | + } |
| 166 | + |
| 167 | + const since = new Date(now().getTime() - SINCE_DAYS * 24 * 60 * 60 * 1000).toISOString() |
| 168 | + const apiPath = `https://api.github.com/repos/${owner}/${name}/commits?since=${since}` |
| 169 | + |
| 170 | + let commits: CommitEntry[] |
| 171 | + try { |
| 172 | + commits = await fetchCommitPages(owner, name, since, deps) |
| 173 | + } catch (err) { |
| 174 | + log.warn( |
| 175 | + { repoId: target.repoId, owner, name, errMsg: (err as Error).message }, |
| 176 | + 'Commit lookup failed', |
| 177 | + ) |
| 178 | + return [] |
| 179 | + } |
| 180 | + |
| 181 | + const authors = aggregateTopCommitters(commits) |
| 182 | + const fetchedAt = new Date().toISOString() |
| 183 | + return mapCommittersToContacts(authors, fetchedAt, apiPath) |
| 184 | +} |
0 commit comments