scripts/backfill_github_user_ids.ts
Rama main · réplica de lectura del repositorio original.
/*
* Backfill public.users.github_id from existing GitHub usernames.
*
* Dry run:
* bun run admin:backfill-github-user-ids
*
* Apply:
* bun run admin:backfill-github-user-ids --apply
*
* Optional:
* bun run admin:backfill-github-user-ids --apply --limit=100
* bun run admin:backfill-github-user-ids --cursor=<user-uuid>
* bun run admin:backfill-github-user-ids --env-file=./internal/cloudflare/.env.preprod
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import process from 'node:process'
import { createSupabaseServiceClient, DEFAULT_ENV_FILE, getArgValue, loadEnv, parsePositiveInteger } from './admin_stripe_backfill_utils.ts'
const DEFAULT_PAGE_SIZE = 100
const DEFAULT_DELAY_MS = 100
const DEFAULT_REPORT_FILE = './tmp/github_user_id_backfill_report.json'
const GITHUB_API_URL = 'https://api.github.com'
type SupabaseClient = ReturnType<typeof createSupabaseServiceClient>
interface CandidateUser {
github_id: number | null
github_username: string | null
id: string
}
interface GitHubUser {
id: number
login: string
}
interface BackfillFailure {
githubUsername: string
message: string
userId: string
}
interface BackfillNotFound {
githubUsername: string
userId: string
}
interface BackfillResult {
githubId: number
githubUsername: string
status: 'dry_run' | 'updated' | 'skipped_after_recheck'
userId: string
}
function printHelp() {
console.log(`Backfill public.users.github_id from existing GitHub usernames.
Usage:
bun run admin:backfill-github-user-ids [options]
Options:
--apply Update only rows still missing github_id. Default: dry run.
--limit=N Process at most N users. Default: all candidates.
--cursor=UUID Resume after a public.users.id cursor.
--delay-ms=N Delay between GitHub calls. Default: ${DEFAULT_DELAY_MS}.
--env-file=PATH Env file to load. Default: ${DEFAULT_ENV_FILE}.
--report-file=PATH JSON report output. Default: ${DEFAULT_REPORT_FILE}.
--help Show this help.
Required env:
SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY
Optional env:
GITHUB_TOKEN Raises GitHub's API rate limit from 60 to 5,000 requests/hour.
The script does not write usernames and never overwrites an existing github_id.`)
}
function parseNonNegativeInteger(value: string | null, label: string, fallback: number) {
if (value === null)
return fallback
const parsed = Number.parseInt(value, 10)
if (!Number.isInteger(parsed) || parsed < 0)
throw new Error(`${label} must be a non-negative integer`)
return parsed
}
function getGitHubToken(env: Record<string, string | undefined>) {
return process.env.GITHUB_TOKEN?.trim() || env.GITHUB_TOKEN?.trim() || null
}
async function sleep(ms: number) {
if (ms > 0)
await new Promise(resolve => setTimeout(resolve, ms))
}
async function lookupGitHubUser(username: string, token: string | null): Promise<GitHubUser | null> {
const response = await fetch(`${GITHUB_API_URL}/users/${encodeURIComponent(username)}`, {
headers: {
'accept': 'application/vnd.github+json',
'x-github-api-version': '2022-11-28',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(15_000),
})
if (response.status === 404)
return null
if (response.status === 429 || (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0')) {
const reset = response.headers.get('x-ratelimit-reset')
const resetHint = reset ? `; resets at ${new Date(Number(reset) * 1000).toISOString()}` : ''
throw new Error(`GitHub rate limit reached${resetHint}`)
}
if (!response.ok)
throw new Error(`GitHub lookup failed with HTTP ${response.status}`)
const data = await response.json() as Partial<GitHubUser>
if (!data || typeof data.id !== 'number' || typeof data.login !== 'string')
throw new Error('GitHub returned an invalid user profile')
return { id: data.id, login: data.login }
}
async function getCandidatePage(client: SupabaseClient, cursor: string | null, pageSize: number) {
let query = client
.from('users')
.select('id, github_username, github_id')
.not('github_username', 'is', null)
.is('github_id', null)
.order('id', { ascending: true })
.limit(pageSize)
if (cursor)
query = query.gt('id', cursor)
const { data, error } = await query
if (error)
throw new Error(`Failed to load candidates: ${error.message}`)
return (data ?? []) as CandidateUser[]
}
async function updateGithubId(client: SupabaseClient, candidate: CandidateUser, githubId: number) {
const { data, error } = await client
.from('users')
.update({ github_id: githubId })
.eq('id', candidate.id)
.eq('github_username', candidate.github_username!)
.is('github_id', null)
.select('id')
.maybeSingle()
if (error)
throw new Error(`Failed to update ${candidate.id}: ${error.message}`)
return !!data
}
async function writeReport(filePath: string, report: unknown) {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${JSON.stringify(report, null, 2)}\n`)
}
async function main() {
const args = process.argv.slice(2)
if (args.includes('--help')) {
printHelp()
return
}
const apply = args.includes('--apply')
const envFile = getArgValue(args, '--env-file') ?? DEFAULT_ENV_FILE
const reportFile = getArgValue(args, '--report-file') ?? DEFAULT_REPORT_FILE
const cursor = getArgValue(args, '--cursor')
const limit = parsePositiveInteger(getArgValue(args, '--limit'), '--limit', Number.MAX_SAFE_INTEGER)
const delayMs = parseNonNegativeInteger(getArgValue(args, '--delay-ms'), '--delay-ms', DEFAULT_DELAY_MS)
const env = { ...await loadEnv(envFile), ...process.env }
const client = createSupabaseServiceClient(env)
const githubToken = getGitHubToken(env)
const failures: BackfillFailure[] = []
const notFound: BackfillNotFound[] = []
const results: BackfillResult[] = []
let lastCursor = cursor
let processed = 0
let stoppedForRateLimit = false
while (processed < limit) {
const page = await getCandidatePage(client, lastCursor, Math.min(DEFAULT_PAGE_SIZE, limit - processed))
if (page.length === 0)
break
for (const candidate of page) {
lastCursor = candidate.id
processed += 1
const username = candidate.github_username?.trim()
if (!username)
continue
try {
const githubUser = await lookupGitHubUser(username, githubToken)
if (!githubUser) {
notFound.push({ userId: candidate.id, githubUsername: username })
continue
}
if (!apply) {
results.push({ userId: candidate.id, githubUsername: githubUser.login, githubId: githubUser.id, status: 'dry_run' })
continue
}
const updated = await updateGithubId(client, candidate, githubUser.id)
results.push({
userId: candidate.id,
githubUsername: githubUser.login,
githubId: githubUser.id,
status: updated ? 'updated' : 'skipped_after_recheck',
})
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
failures.push({ userId: candidate.id, githubUsername: username, message })
if (message.startsWith('GitHub rate limit reached')) {
stoppedForRateLimit = true
break
}
}
finally {
await sleep(delayMs)
}
}
if (stoppedForRateLimit || page.length < Math.min(DEFAULT_PAGE_SIZE, limit - processed))
break
}
const report = {
mode: apply ? 'apply' : 'dry_run',
processed,
updated: results.filter(result => result.status === 'updated').length,
wouldUpdate: results.filter(result => result.status === 'dry_run').length,
skippedAfterRecheck: results.filter(result => result.status === 'skipped_after_recheck').length,
notFound,
failures,
lastCursor,
stoppedForRateLimit,
}
await writeReport(reportFile, report)
console.log(JSON.stringify(report, null, 2))
if (stoppedForRateLimit)
process.exitCode = 2
}
void main().catch((error) => {
console.error(error)
process.exitCode = 1
})