Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions app/api/github-history/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---
Copy link

Copilot AI Jan 22, 2026

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.

Suggested change
// --- Route-level in-memory cache (per path) ---
// --- Route-level in-memory cache (per path) ---
// NOTE: This cache is process-local and best-effort only. It is intended as a minor
// optimization for warm instances and does not provide any persistence guarantees.
// In serverless or multi-instance deployments (e.g., Vercel), this Map will not be
// shared across function invocations or instances and may be frequently reset.
// For stronger and cross-instance caching guarantees, use a distributed cache
// (such as Redis) instead of, or in addition to, this in-memory cache.

Copilot uses AI. Check for mistakes.
type CacheEntry = { expiresAt: number; value: BatchResultItem };
const routeCache = new Map<string, CacheEntry>();
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache does not have a maximum size limit, which could lead to unbounded memory growth over time. Consider implementing a cache size limit with an LRU (Least Recently Used) eviction policy to prevent memory exhaustion.

Copilot uses AI. Check for mistakes.

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");
Expand Down Expand Up @@ -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
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The batchSize variable is defined but the outer loop processes missList in batches without using the results in a batched manner. The inner runWithConcurrency already handles concurrency control with CONCURRENCY=5. The batching logic appears redundant and doesn't provide additional benefit. Consider removing the outer batching loop or clarifying its purpose.

Copilot uses AI. Check for mistakes.
}

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),
},
}
);
}
11 changes: 4 additions & 7 deletions components/LatestRulesCard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"use client";

import Link from "next/link";
import { RiTimeFill } from "react-icons/ri";
import { Card } from "@/components/ui/card";
import { LatestRule } from "@/models/LatestRule";
import { timeAgo } from "@/lib/dateUtils";
import { RiTimeFill } from "react-icons/ri";
import Link from "next/link";
import { LatestRule } from "@/models/LatestRule";

interface LatestRulesProps {
rules: LatestRule[];
Expand All @@ -27,10 +27,7 @@ export default function LatestRulesCard({ rules }: LatestRulesProps) {
))}
</ul>

<Link
href="/latest-rules/?size=50"
className="px-4 py-2 rounded-md inline-flex items-center text-ssw-red hover:underline"
>
<Link href="/latest-rules/?size=50" className="px-4 py-2 rounded-md inline-flex items-center text-ssw-red hover:underline">
See more
</Link>
</Card>
Expand Down
Loading