diff --git a/bin/buildContributors.js b/bin/buildContributors.js index 47caf1a..9660a99 100644 --- a/bin/buildContributors.js +++ b/bin/buildContributors.js @@ -124,7 +124,7 @@ function getDeletedMarkdownFiles(baseBranch) { } } -// example: src/pages/about/index.md -> about/ +// example: src/pages/about/index.md -> /about/ function fileToPagePath(file) { return file .replace(/^src\/pages/, '') @@ -196,12 +196,12 @@ try { } const { owner, repo } = repoInfo; - logStep(`Repository`, `${owner}/${repo}`); + logStep('Repository', `${owner}/${repo}`); const token = getToken(); - const headers = { 'Accept': 'application/vnd.github+json' }; + const headers = { Accept: 'application/vnd.github+json' }; if (token) { - headers['Authorization'] = `Bearer ${token}`; + headers.Authorization = `Bearer ${token}`; } else { logStep('No credentials found — attempting unauthenticated API calls'); logStep('This works for public repos (60 req/hr limit)'); @@ -254,13 +254,13 @@ try { } } - logStep(`Files to process`, `${filesToProcess.length}`); + logStep('Files to process', `${filesToProcess.length}`); const newData = []; let apiFailed = false; for (const file of filesToProcess) { - logStep(`Processing`, file); + logStep('Processing', file); const result = await getFileContributors(owner, repo, file, headers, branch); if (!result) { diff --git a/bin/buildContributorsV2.js b/bin/buildContributorsV2.js new file mode 100644 index 0000000..7162508 --- /dev/null +++ b/bin/buildContributorsV2.js @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; + +const { log, verbose, logSection, logStep, getMarkdownFiles } = await import('./scriptUtils.js'); + +const __dirname = process.cwd(); +verbose(`Current directory: ${__dirname}`); + +const CONTRIBUTORS_FILE_PATH = path.join('src', 'pages', 'contributors.json'); +const FULL_BUILD = process.argv.includes('--all'); + +function getTokenFromCredentialHelper() { + try { + const output = execSync( + 'printf "protocol=https\\nhost=github.com\\n" | git credential fill', + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + const match = output.match(/^password=(.+)$/m); + return match?.[1]?.trim() ?? null; + } catch { + return null; + } +} + +function getToken() { + // for github actions + if (process.env.GITHUB_TOKEN) { + logStep('Using GITHUB_TOKEN from environment'); + return process.env.GITHUB_TOKEN; + } + + // for local development + const credentialToken = getTokenFromCredentialHelper(); + if (credentialToken) { + logStep('Using token from git credential helper'); + return credentialToken; + } + + return null; +} + +function getRepoInfo() { + // for github actions + if (process.env.GITHUB_REPOSITORY) { + const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); + return { owner, repo }; + } + + // for local development + try { + const remote = execSync('git remote get-url origin', { encoding: 'utf8' }).trim(); + const match = remote.match(/github\.com[/:]([^/]+)\/([^/.]+)/); + if (match) { + return { owner: match[1], repo: match[2] }; + } + } catch { + // fall through + } + + return null; +} + +function getCurrentBranch() { + // for github actions + if (process.env.GITHUB_HEAD_REF) { + return process.env.GITHUB_HEAD_REF; + } + + // for local development + try { + return execSync('git branch --show-current', { encoding: 'utf8' }).trim() || null; + } catch { + return null; + } +} + +function getBaseBranch() { + // for github actions + if (process.env.BASE_SHA) { + return process.env.BASE_SHA; + } + + // for local development + try { + const defaultRef = execSync('git symbolic-ref refs/remotes/origin/HEAD', { encoding: 'utf8' }).trim(); + return defaultRef.replace('refs/remotes/', ''); + } catch { + // fall through + } + + return null; +} + +// returns added, copied, modified, and renamed markdown files. (deleted files are not included). +function getChangedMarkdownFiles(baseBranch) { + try { + const diff = execSync( + `git diff --name-only --diff-filter=ACMR ${baseBranch}...HEAD -- ":(glob)src/pages/**/*.md"`, + { encoding: 'utf8' } + ).trim(); + + if (!diff) return []; + return diff.split('\n').filter(Boolean); + } catch { + return []; + } +} + +// returns deleted markdown files. +function getDeletedMarkdownFiles(baseBranch) { + try { + const diff = execSync( + `git diff --name-only --diff-filter=D ${baseBranch}...HEAD -- ":(glob)src/pages/**/*.md"`, + { encoding: 'utf8' } + ).trim(); + + if (!diff) return []; + return diff.split('\n').filter(Boolean); + } catch { + return []; + } +} + +// example: src/pages/about/index.md -> /about/ +function fileToPagePath(file) { + return file + .replace(/^src\/pages/, '') + .replace(/\.md$/, '') + .replace(/\/index$/, '/'); +} + +// example: 2026-03-04T12:00:00Z -> 3/4/2026 +function formatDate(isoDate) { + if (!isoDate) return ''; + const d = new Date(isoDate); + return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`; +} + +function readExistingContributors() { + try { + const content = fs.readFileSync(CONTRIBUTORS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(content); + return Array.isArray(parsed.data) ? parsed.data : []; + } catch { + return []; + } +} + +async function getFileContributors(owner, repo, filePath, headers, branch) { + const encodedPath = encodeURIComponent(filePath); + const branchParam = branch ? `&sha=${encodeURIComponent(branch)}` : ''; + + // We only fetch the last 100 commits (one request, no pagination) to limit API rate + // usage. We don't need every contributor — typically 3-20 is enough. 100 is a safe + // upper bound that costs the same as fetching 20 (one request either way). + const url = `https://api.github.com/repos/${owner}/${repo}/commits?path=${encodedPath}&per_page=100${branchParam}`; + + const response = await fetch(url, { headers }); + + if (!response.ok) { + logStep(`Failed to fetch commits for ${filePath}`, `${response.status} ${response.statusText}`); + return null; + } + + const commits = await response.json(); + + if (!Array.isArray(commits) || commits.length === 0) { + return null; + } + + const avatars = [...new Set( + commits + .map((c) => c.author?.avatar_url) + .filter(Boolean) + )]; + + const lastUpdated = formatDate(commits[0]?.commit?.author?.date ?? null); + + return { + avatars, + lastUpdated, + }; +} + +try { + logSection('BUILD CONTRIBUTORS'); + logStep('Starting contributors build process'); + + const repoInfo = getRepoInfo(); + if (!repoInfo) { + log('Could not determine repository info. Skipping contributors build.', 'warn'); + process.exit(0); + } + + const { owner, repo } = repoInfo; + logStep('Repository', `${owner}/${repo}`); + + const token = getToken(); + const headers = { Accept: 'application/vnd.github+json' }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } else { + logStep('No credentials found — attempting unauthenticated API calls'); + logStep('This works for public repos (60 req/hr limit)'); + } + + const branch = getCurrentBranch(); + if (branch) { + logStep('Branch', branch); + const branchCheckUrl = `https://api.github.com/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`; + const branchCheckRes = await fetch(branchCheckUrl, { headers }); + if (!branchCheckRes.ok) { + log(`Branch "${branch}" not found on GitHub (${branchCheckRes.status}). Contributors are fetched from the GitHub API using the current branch, so it must be pushed to the remote before running this script.`, 'warn'); + process.exit(0); + } + } + + let filesToProcess; + let existingContributors = []; + let deletedPages = new Set(); + + if (FULL_BUILD) { + logStep('Mode', 'full build (--all)'); + filesToProcess = getMarkdownFiles(__dirname); + } else { + const baseBranch = getBaseBranch(); + + if (!baseBranch) { + logStep('Could not determine base branch — falling back to full build'); + filesToProcess = getMarkdownFiles(__dirname); + } else { + logStep('Base branch', baseBranch); + + const changedFiles = getChangedMarkdownFiles(baseBranch); + const deletedFiles = getDeletedMarkdownFiles(baseBranch); + deletedPages = new Set(deletedFiles.map(fileToPagePath)); + existingContributors = readExistingContributors(); + + if (existingContributors.length === 0) { + logStep('No existing contributors.json — falling back to full build'); + filesToProcess = getMarkdownFiles(__dirname); + } else if (changedFiles.length === 0 && deletedFiles.length === 0) { + logStep('No markdown files changed — keeping existing contributors.json'); + process.exit(0); + } else { + logStep('Changed files', `${changedFiles.length}`); + logStep('Deleted files', `${deletedFiles.length}`); + logStep('Existing contributor entries', `${existingContributors.length}`); + filesToProcess = changedFiles; + } + } + } + + const fetchedContributors = []; + + for (const file of filesToProcess) { + const fileData = await getFileContributors(owner, repo, file, headers, branch); + if (!fileData) continue; + + fetchedContributors.push({ + page: fileToPagePath(file), + avatars: fileData.avatars, + lastUpdated: fileData.lastUpdated, + }); + } + + const preservedContributors = existingContributors.filter((entry) => !deletedPages.has(entry.page) && !fetchedContributors.some((result) => result.page === entry.page)); + const updatedContributors = FULL_BUILD ? fetchedContributors : [...preservedContributors, ...fetchedContributors]; + + updatedContributors.sort((a, b) => a.page.localeCompare(b.page)); + + fs.writeFileSync( + CONTRIBUTORS_FILE_PATH, + `${JSON.stringify({ total: updatedContributors.length, offset: 0, limit: updatedContributors.length, data: updatedContributors, ':type': 'sheet' }, null, 2)}\n`, + ); + logStep('Updated contributors file', CONTRIBUTORS_FILE_PATH); + logStep('Total entries', `${updatedContributors.length}`); +} catch (error) { + log(error.stack || error.message, 'error'); + process.exit(1); +}