Skip to content

Commit 8981b85

Browse files
committed
Enhance GitHub stats action and background function
- Refactored `getStats.js` to include hardcoded endpoints for BASE_URL and Netlify function URL, improving configuration clarity. - Implemented retry logic for GitHub API calls to handle rate limits and errors more gracefully. - Added functions to fetch missing commits, pull requests, and issues, streamlining data collection from GitHub. - Updated the Netlify background function to process incoming data more efficiently, including upserting contributors, commits, pull requests, and issues into Supabase. - Improved error handling and logging for better debugging and monitoring of the data processing flow.
1 parent f28fd67 commit 8981b85

File tree

3 files changed

+460
-727
lines changed

3 files changed

+460
-727
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { NextApiRequest, NextApiResponse } from 'next'
2+
import { supabase } from '../../../lib/supabase'
3+
4+
type ExistingIdsResponse = {
5+
repoId: number | null
6+
commitShas: string[]
7+
prNumbers: number[]
8+
issueNumbers: number[]
9+
}
10+
11+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
12+
if (req.method !== 'GET') {
13+
return res.status(405).json({ error: 'Method not allowed' })
14+
}
15+
16+
try {
17+
const orgLogin = (req.query.org as string | undefined)?.trim()
18+
const repoName = (req.query.repo as string | undefined)?.trim()
19+
20+
if (!orgLogin || !repoName) {
21+
return res.status(400).json({ error: 'Missing required query params: org, repo' })
22+
}
23+
24+
// Get org id
25+
const { data: orgs, error: orgError } = await supabase
26+
.from('github_orgs')
27+
.select('id')
28+
.eq('login', orgLogin)
29+
.limit(1)
30+
31+
if (orgError) throw new Error(orgError.message)
32+
if (!orgs || orgs.length === 0) {
33+
return res.status(404).json({ error: 'Organization not found' })
34+
}
35+
36+
const orgId = orgs[0].id as number
37+
38+
// Get repo id by org_id + name
39+
const { data: repos, error: repoError } = await supabase
40+
.from('github_repos')
41+
.select('id, name')
42+
.eq('org_id', orgId)
43+
.eq('name', repoName)
44+
.limit(1)
45+
46+
if (repoError) throw new Error(repoError.message)
47+
if (!repos || repos.length === 0) {
48+
const empty: ExistingIdsResponse = { repoId: null, commitShas: [], prNumbers: [], issueNumbers: [] }
49+
return res.status(200).json(empty)
50+
}
51+
52+
const repoId = repos[0].id as number
53+
54+
// Fetch existing commit SHAs, PR numbers, Issue numbers in parallel
55+
const [commitsResp, prsResp, issuesResp] = await Promise.all([
56+
supabase.from('commits').select('sha').eq('repo_id', repoId),
57+
supabase.from('pull_requests').select('number').eq('repo_id', repoId),
58+
supabase.from('issues').select('number').eq('repo_id', repoId),
59+
])
60+
61+
if (commitsResp.error) throw new Error(commitsResp.error.message)
62+
if (prsResp.error) throw new Error(prsResp.error.message)
63+
if (issuesResp.error) throw new Error(issuesResp.error.message)
64+
65+
const commitShas = (commitsResp.data ?? []).map((c: any) => c.sha as string)
66+
const prNumbers = (prsResp.data ?? []).map((p: any) => p.number as number)
67+
const issueNumbers = (issuesResp.data ?? []).map((i: any) => i.number as number)
68+
69+
const payload: ExistingIdsResponse = { repoId, commitShas, prNumbers, issueNumbers }
70+
return res.status(200).json(payload)
71+
} catch (err: any) {
72+
console.error('existing-ids API error:', err?.message || err)
73+
return res.status(500).json({ error: 'Internal server error' })
74+
}
75+
}
76+
77+

apps/shared-backend/github-actions/github-stats-action/getStats.js

Lines changed: 161 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,16 @@ import fetch from 'node-fetch'
66
const ORG = process.env.ORG
77
const REPO = process.env.REPO
88
const GITHUB_TOKEN = process.env.GITHUB_TOKEN
9+
// Hardcoded endpoints
10+
const BASE_URL = 'https://gov.meshjs.dev/'
11+
const NETLIFY_FUNCTION_URL = 'https://glittering-chebakia-09bd42.netlify.app/.netlify/functions/github-stats-background'
912

1013
// Debug logging
1114
console.log('🔍 Environment variables:')
1215
console.log(` ORG: ${process.env.ORG}`)
1316
console.log(` REPO: ${process.env.REPO}`)
17+
console.log(` BASE_URL: ${BASE_URL}`)
18+
console.log(` NETLIFY_FUNCTION_URL: ${NETLIFY_FUNCTION_URL}`)
1419
console.log(` GITHUB_TOKEN: ${GITHUB_TOKEN ? '[REDACTED]' : '(not provided)'}`)
1520

1621
// Input validation function
@@ -42,6 +47,8 @@ function validateInputs() {
4247
errors.push('GITHUB_TOKEN must be a non-empty string')
4348
}
4449

50+
// BASE_URL and NETLIFY_FUNCTION_URL are hardcoded
51+
4552
return errors
4653
}
4754

@@ -58,13 +65,13 @@ if (validationErrors.length > 0) {
5865
const parsedOrg = ORG.trim()
5966
const parsedRepo = REPO.trim()
6067
const parsedGithubToken = GITHUB_TOKEN.trim()
68+
const parsedBaseUrl = BASE_URL.trim().replace(/\/$/, '')
69+
const parsedFunctionUrl = NETLIFY_FUNCTION_URL.trim()
6170

6271
console.log('📊 Resolved values:')
6372
console.log(` ORG: ${parsedOrg}`)
6473
console.log(` REPO: ${parsedRepo}`)
65-
66-
// Netlify function configuration - hardcoded URL
67-
const NETLIFY_FUNCTION_URL = 'https://glittering-chebakia-09bd42.netlify.app/.netlify/functions/github-stats-background'
74+
console.log(` BASE_URL: ${parsedBaseUrl}`)
6875

6976
// Helper function to make HTTP requests
7077
async function makeRequest(url, options = {}) {
@@ -103,41 +110,167 @@ async function makeRequest(url, options = {}) {
103110
}
104111
}
105112

113+
// Retry helper for GitHub API calls
114+
async function retryWithBackoff(fn, maxRetries = 5, initialDelayMs = 1000) {
115+
let attempt = 0
116+
let delay = initialDelayMs
117+
// eslint-disable-next-line no-constant-condition
118+
while (true) {
119+
try {
120+
return await fn()
121+
} catch (error) {
122+
const status = error.status || error.code || error?.response?.status
123+
if (attempt < maxRetries && (status === 403 || status === 429 || (typeof status === 'number' && status >= 500))) {
124+
attempt += 1
125+
console.warn(`Retrying after error ${status} (attempt ${attempt}/${maxRetries})...`)
126+
await new Promise((r) => setTimeout(r, delay))
127+
delay *= 2
128+
continue
129+
}
130+
throw error
131+
}
132+
}
133+
}
134+
135+
function ghHeaders() {
136+
return {
137+
'Accept': 'application/vnd.github.v3+json',
138+
'Authorization': `token ${parsedGithubToken}`
139+
}
140+
}
141+
142+
async function fetchExistingIds() {
143+
const url = `${parsedBaseUrl}/api/github/existing-ids?org=${encodeURIComponent(parsedOrg)}&repo=${encodeURIComponent(parsedRepo)}`
144+
return await makeRequest(url, { method: 'GET' })
145+
}
146+
147+
async function fetchAllMissingCommits(existingShasSet) {
148+
const results = []
149+
let page = 1
150+
while (true) {
151+
const data = await retryWithBackoff(async () => {
152+
const resp = await fetch(`https://api.github.com/repos/${parsedOrg}/${parsedRepo}/commits?per_page=100&page=${page}`, { headers: ghHeaders() })
153+
if (!resp.ok) throw { status: resp.status, message: await resp.text() }
154+
return resp.json()
155+
})
156+
if (!Array.isArray(data) || data.length === 0) break
157+
for (const item of data) {
158+
if (!existingShasSet.has(item.sha)) {
159+
// Ensure we have full commit details
160+
const details = await retryWithBackoff(async () => {
161+
const resp = await fetch(`https://api.github.com/repos/${parsedOrg}/${parsedRepo}/commits/${item.sha}`, { headers: ghHeaders() })
162+
if (!resp.ok) throw { status: resp.status, message: await resp.text() }
163+
return resp.json()
164+
})
165+
results.push(details)
166+
}
167+
}
168+
page += 1
169+
}
170+
return results
171+
}
172+
173+
async function fetchAllMissingPulls(existingNumbersSet) {
174+
const results = []
175+
let page = 1
176+
while (true) {
177+
const data = await retryWithBackoff(async () => {
178+
const resp = await fetch(`https://api.github.com/repos/${parsedOrg}/${parsedRepo}/pulls?state=all&per_page=100&page=${page}`, { headers: ghHeaders() })
179+
if (!resp.ok) throw { status: resp.status, message: await resp.text() }
180+
return resp.json()
181+
})
182+
if (!Array.isArray(data) || data.length === 0) break
183+
for (const pr of data) {
184+
if (!existingNumbersSet.has(pr.number)) {
185+
const prDetails = await retryWithBackoff(async () => {
186+
const resp = await fetch(`https://api.github.com/repos/${parsedOrg}/${parsedRepo}/pulls/${pr.number}`, { headers: ghHeaders() })
187+
if (!resp.ok) throw { status: resp.status, message: await resp.text() }
188+
return resp.json()
189+
})
190+
const prCommits = await retryWithBackoff(async () => {
191+
const resp = await fetch(`https://api.github.com/repos/${parsedOrg}/${parsedRepo}/pulls/${pr.number}/commits`, { headers: ghHeaders() })
192+
if (!resp.ok) throw { status: resp.status, message: await resp.text() }
193+
return resp.json()
194+
})
195+
results.push({ details: prDetails, commits: prCommits })
196+
}
197+
}
198+
page += 1
199+
}
200+
return results
201+
}
202+
203+
async function fetchAllMissingIssues(existingNumbersSet) {
204+
const results = []
205+
let page = 1
206+
while (true) {
207+
const data = await retryWithBackoff(async () => {
208+
const resp = await fetch(`https://api.github.com/repos/${parsedOrg}/${parsedRepo}/issues?state=all&per_page=100&page=${page}`, { headers: ghHeaders() })
209+
if (!resp.ok) throw { status: resp.status, message: await resp.text() }
210+
return resp.json()
211+
})
212+
if (!Array.isArray(data) || data.length === 0) break
213+
for (const issue of data) {
214+
if (issue.pull_request) continue // skip PRs
215+
if (!existingNumbersSet.has(issue.number)) {
216+
results.push(issue)
217+
}
218+
}
219+
page += 1
220+
}
221+
return results
222+
}
223+
106224
// Main function
107225
async function main() {
108226
console.log(`🚀 Starting GitHub stats collection for repository: ${parsedOrg}/${parsedRepo}`)
109227

110228
// Trigger the Netlify background function
111-
console.log('📡 Triggering Netlify background function...')
229+
console.log('📥 Fetching existing IDs from mesh-gov API...')
230+
const existing = await fetchExistingIds()
231+
const existingCommitShas = new Set(existing.commitShas || [])
232+
const existingPrNumbers = new Set(existing.prNumbers || [])
233+
const existingIssueNumbers = new Set(existing.issueNumbers || [])
112234

113-
const functionUrl = new URL(NETLIFY_FUNCTION_URL)
114-
functionUrl.searchParams.set('org', parsedOrg)
115-
functionUrl.searchParams.set('repo', parsedRepo)
116-
functionUrl.searchParams.set('githubToken', parsedGithubToken)
235+
console.log(`🔎 Found existing: commits=${existingCommitShas.size}, PRs=${existingPrNumbers.size}, issues=${existingIssueNumbers.size}`)
117236

118-
console.log(`🌐 Calling URL: ${functionUrl.toString().replace(parsedGithubToken, '[REDACTED]')}`)
237+
console.log('⬇️ Fetching missing commits from GitHub...')
238+
const missingCommits = await fetchAllMissingCommits(existingCommitShas)
239+
console.log(`🧮 Missing commits: ${missingCommits.length}`)
119240

120-
try {
121-
const functionResponse = await makeRequest(functionUrl.toString(), {
122-
method: 'GET'
123-
})
241+
console.log('⬇️ Fetching missing pull requests from GitHub...')
242+
const missingPulls = await fetchAllMissingPulls(existingPrNumbers)
243+
console.log(`🧮 Missing PRs: ${missingPulls.length}`)
124244

125-
console.log('✅ Background function triggered successfully')
126-
console.log(`📊 Response: ${JSON.stringify(functionResponse, null, 2)}`)
127-
128-
// If the response indicates success (even if it's not JSON), we're done
129-
if (functionResponse.success !== false) {
130-
console.log('✅ Background function appears to have been triggered successfully')
131-
console.log('🎉 GitHub stats collection initiated - the background function will handle the rest')
132-
process.exit(0)
133-
} else {
134-
console.error('❌ Background function returned an error')
135-
process.exit(1)
136-
}
137-
} catch (error) {
138-
console.error('❌ Failed to trigger background function:', error.message)
139-
process.exit(1)
245+
console.log('⬇️ Fetching missing issues from GitHub...')
246+
const missingIssues = await fetchAllMissingIssues(existingIssueNumbers)
247+
console.log(`🧮 Missing issues: ${missingIssues.length}`)
248+
249+
const payload = {
250+
org: parsedOrg,
251+
repo: parsedRepo,
252+
commits: missingCommits,
253+
pulls: missingPulls,
254+
issues: missingIssues,
140255
}
256+
257+
if (
258+
(missingCommits && missingCommits.length) ||
259+
(missingPulls && missingPulls.length) ||
260+
(missingIssues && missingIssues.length)
261+
) {
262+
console.log('📡 Sending new data to Netlify background function...')
263+
const response = await makeRequest(parsedFunctionUrl, {
264+
method: 'POST',
265+
body: JSON.stringify(payload),
266+
})
267+
console.log(`✅ Background function response: ${JSON.stringify(response, null, 2)}`)
268+
} else {
269+
console.log('✅ No new data to send')
270+
}
271+
272+
console.log('🎉 Done')
273+
process.exit(0)
141274
}
142275

143276
// Run the main function

0 commit comments

Comments
 (0)