-
Notifications
You must be signed in to change notification settings - Fork 14
Get latest committer info from Git commit history #2370
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
74a67c1
6b41973
c41fe17
ef98204
f39df77
53d684f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,101 @@ import { getGitHubAppToken } from "@/lib/services/github/github.utils"; | |
| import { fetchGitHub, findCompleteFileHistory, findLatestNonExcludedCommit, getAlternateAuthorName } from "./util"; | ||
|
|
||
| const GITHUB_ACTIVE_BRANCH = process.env.NEXT_PUBLIC_TINA_BRANCH || "main"; | ||
| const CACHE_TTL = 3600; | ||
| const MAX_PATHS = 50; | ||
| const CONCURRENCY = 5; | ||
|
|
||
| type Mode = "updated" | "created"; | ||
|
|
||
| type BatchRequestBody = { | ||
| mode?: Mode; | ||
| ref?: string; // optional override branch | ||
| paths: string[]; | ||
| }; | ||
|
|
||
| type BatchResultItem = | ||
| | { | ||
| path: string; | ||
| authorName: string | null; | ||
| sha: string | null; | ||
| date: string | null; | ||
| historyUrl: string; | ||
| } | ||
| | { | ||
| path: string; | ||
| error: string; | ||
| historyUrl: string; | ||
| }; | ||
|
|
||
| // --- Route-level in-memory cache (per path) --- | ||
| type CacheEntry = { expiresAt: number; value: BatchResultItem }; | ||
| const routeCache = new Map<string, CacheEntry>(); | ||
|
||
|
|
||
| function cacheKey(mode: Mode, ref: string, path: string) { | ||
| return `${mode}::${ref}::${path}`; | ||
| } | ||
|
|
||
| function cacheGet(key: string): BatchResultItem | null { | ||
| const hit = routeCache.get(key); | ||
| if (!hit) return null; | ||
| if (Date.now() > hit.expiresAt) { | ||
| routeCache.delete(key); | ||
| return null; | ||
| } | ||
| return hit.value; | ||
| } | ||
|
|
||
| function cacheSet(key: string, value: BatchResultItem) { | ||
| routeCache.set(key, { value, expiresAt: Date.now() + CACHE_TTL * 1000 }); | ||
| } | ||
|
|
||
| function purgeExpiredCacheEntries() { | ||
| const now = Date.now(); | ||
| for (const [key, entry] of routeCache) { | ||
| if (entry.expiresAt <= now) routeCache.delete(key); | ||
| } | ||
| } | ||
|
|
||
| function normalizePath(path: string | null | undefined) { | ||
| if (!path) return null; | ||
| return path.replace(/\\/g, "/").replace(/^\/+/, "").trim(); | ||
| } | ||
|
|
||
| function buildHistoryUrl(owner: string, repo: string, branch: string, path?: string | null) { | ||
| return path ? `https://github.com/${owner}/${repo}/commits/${branch}/${path}` : `https://github.com/${owner}/${repo}/commits/${branch}`; | ||
| } | ||
|
|
||
| async function runWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> { | ||
| const results: R[] = new Array(items.length); | ||
| const iterator = items.entries(); | ||
|
|
||
| async function worker() { | ||
| for (const [index, item] of iterator) { | ||
| results[index] = await fn(item, index); | ||
| } | ||
| } | ||
|
|
||
| await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())); | ||
| return results; | ||
| } | ||
|
|
||
| /** | ||
| * Decide which author name to return based on your existing exclusion rules: | ||
| * - if commit is excluded by author/bot but has allowed co-author => util returns that name via getAlternateAuthorName | ||
| * - if excluded and no allowed co-author => return null | ||
| * - if not excluded => return primary author name (commit.commit.author.name) | ||
| */ | ||
| function pickAuthorNameOrNull(commit: GitHubCommit | null): string | null { | ||
| if (!commit) return null; | ||
|
|
||
| const alt = getAlternateAuthorName(commit); | ||
| if (alt) return alt; | ||
|
|
||
| const primaryName = commit.commit?.author?.name ?? null; | ||
| return primaryName; | ||
| } | ||
|
|
||
| // ---------------- GET (keep existing behavior) ---------------- | ||
| export async function GET(request: Request) { | ||
| const { searchParams } = new URL(request.url); | ||
| const owner = searchParams.get("owner"); | ||
|
|
@@ -107,3 +201,152 @@ export async function GET(request: Request) { | |
| return NextResponse.json({ error: errorMessage, historyUrl }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| const owner = process.env.NEXT_PUBLIC_GITHUB_ORG; | ||
| const repo = process.env.NEXT_PUBLIC_GITHUB_REPO; | ||
|
|
||
| if (!owner || !repo) { | ||
| return NextResponse.json({ error: "Missing owner or repo parameters" }, { status: 400 }); | ||
| } | ||
|
|
||
| const body = (await request.json().catch(() => null)) as BatchRequestBody | null; | ||
| if (!body || !Array.isArray(body.paths)) { | ||
| return NextResponse.json({ error: "Invalid JSON body. Expected { paths: string[], mode?: 'updated'|'created', ref?: string }" }, { status: 400 }); | ||
| } | ||
|
|
||
| const mode: Mode = body.mode === "created" ? "created" : "updated"; | ||
| const ref = (body.ref && body.ref.trim()) || GITHUB_ACTIVE_BRANCH; | ||
|
|
||
| const normalizedPaths = body.paths.map((p) => normalizePath(p)).filter(Boolean) as string[]; | ||
|
|
||
| if (normalizedPaths.length === 0) { | ||
| return NextResponse.json({ error: "No valid paths provided" }, { status: 400 }); | ||
| } | ||
| if (normalizedPaths.length > MAX_PATHS) { | ||
| return NextResponse.json({ error: `Too many paths. Max ${MAX_PATHS}` }, { status: 400 }); | ||
| } | ||
|
|
||
| let githubToken: string; | ||
| try { | ||
| githubToken = await getGitHubAppToken(); | ||
| } catch (error) { | ||
| return NextResponse.json( | ||
| { | ||
| error: "GitHub App authentication failed", | ||
| }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
|
|
||
| const headers: Record<string, string> = { | ||
| Accept: "application/vnd.github.v3+json", | ||
| "User-Agent": "Rules.V3", | ||
| Authorization: `Bearer ${githubToken}`, | ||
| }; | ||
|
|
||
| const results: BatchResultItem[] = new Array(normalizedPaths.length); | ||
|
|
||
| let cacheHits = 0; | ||
| purgeExpiredCacheEntries(); | ||
| const missList: Array<{ index: number; path: string }> = []; | ||
|
|
||
| normalizedPaths.forEach((path, index) => { | ||
| const key = cacheKey(mode, ref, path); | ||
| const cached = cacheGet(key); | ||
| if (cached) { | ||
| results[index] = cached; | ||
| cacheHits++; | ||
| } else { | ||
| missList.push({ index, path }); | ||
| } | ||
| }); | ||
|
|
||
| const batchSize = 10; // Define batch size to limit API calls | ||
| const computed: Array<{ index: number; item: BatchResultItem }> = []; | ||
|
|
||
| for (let i = 0; i < missList.length; i += batchSize) { | ||
| const batch = missList.slice(i, i + batchSize); | ||
|
|
||
| const batchResults = await runWithConcurrency(batch, CONCURRENCY, async ({ index, path }): Promise<{ index: number; item: BatchResultItem }> => { | ||
| const historyUrl = buildHistoryUrl(owner, repo, ref, path); | ||
|
|
||
| try { | ||
| const { commits: allCommitsWithHistory } = await findCompleteFileHistory(owner, repo, path, ref, headers); | ||
|
|
||
| if (!allCommitsWithHistory.length) { | ||
| return { index, item: { path, error: "No commits found", historyUrl } }; | ||
| } | ||
|
|
||
| if (mode === "updated") { | ||
| const latestCommit = findLatestNonExcludedCommit(allCommitsWithHistory); | ||
|
|
||
| if (!latestCommit) { | ||
| return { index, item: { path, authorName: null, sha: null, date: null, historyUrl } }; | ||
| } | ||
|
|
||
| const authorName = pickAuthorNameOrNull(latestCommit); | ||
| return { | ||
| index, | ||
| item: { | ||
| path, | ||
| authorName, | ||
| sha: latestCommit.sha ?? null, | ||
| date: latestCommit.commit?.author?.date ?? null, | ||
| historyUrl, | ||
| }, | ||
| }; | ||
| } else { | ||
| const firstCommit = allCommitsWithHistory[allCommitsWithHistory.length - 1] ?? null; | ||
|
|
||
| const alt = firstCommit ? getAlternateAuthorName(firstCommit) : null; | ||
| if (alt) { | ||
| return { | ||
| index, | ||
| item: { | ||
| path, | ||
| authorName: alt, | ||
| sha: firstCommit.sha ?? null, | ||
| date: firstCommit.commit?.author?.date ?? null, | ||
| historyUrl, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| index, | ||
| item: { | ||
| path, | ||
| authorName: null, | ||
| sha: firstCommit?.sha ?? null, | ||
| date: firstCommit?.commit?.author?.date ?? null, | ||
| historyUrl, | ||
| }, | ||
| }; | ||
| } | ||
| } catch (e: any) { | ||
| return { index, item: { path, error: e?.message ?? String(e), historyUrl } }; | ||
| } | ||
| }); | ||
|
|
||
| computed.push(...batchResults); | ||
| } | ||
|
|
||
| for (const { index, item } of computed) { | ||
| results[index] = item; | ||
| const key = cacheKey(mode, ref, item.path); | ||
| cacheSet(key, item); | ||
|
Comment on lines
+270
to
+338
|
||
| } | ||
|
|
||
| return NextResponse.json( | ||
| { ok: true, owner, repo, mode, ref, results }, | ||
| { | ||
| headers: { | ||
| "Cache-Control": `public, max-age=${CACHE_TTL}`, | ||
| "X-Route-Cache": cacheHits === normalizedPaths.length ? "HIT" : cacheHits === 0 ? "MISS" : "PARTIAL", | ||
| "X-Route-Cache-Hits": String(cacheHits), | ||
| "X-Route-Cache-Total": String(normalizedPaths.length), | ||
| }, | ||
| } | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The in-memory cache (
routeCache) will not persist across serverless function invocations or multiple instances. In production environments with serverless deployments (like Vercel), each request may hit a different instance, making this cache ineffective. Consider using a distributed cache like Redis, or document this limitation clearly.