feat (v1.0.0): initial refactor and redesign
This commit is contained in:
parent
3058aa1ab4
commit
fe9b50b30e
134 changed files with 17792 additions and 3670 deletions
486
lib/github.ts
Normal file
486
lib/github.ts
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
/**
|
||||
* GitHub and Forgejo API integration service with server-side caching.
|
||||
*
|
||||
* @remarks
|
||||
* This module provides secure, cached access to GitHub and Forgejo APIs for:
|
||||
* - Fetching recent repository updates
|
||||
* - Retrieving repository metrics (stars, forks)
|
||||
* - Managing featured project data
|
||||
*
|
||||
* **Security Features:**
|
||||
* - Dual-layer caching (unstable_cache + React cache) prevents API abuse
|
||||
* - 1-hour revalidation protects GitHub PAT from rate limits
|
||||
* - Server-side cache shared across all requests
|
||||
*
|
||||
* **Environment Variables:**
|
||||
* - `GITHUB_PROJECTS_USER` or `GITHUB_USERNAME`: Override default username
|
||||
* - `GITHUB_PROJECTS_PAT` or `GITHUB_PAT`: GitHub Personal Access Token (optional)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { getRecentGitHubRepos, getFeaturedReposWithMetrics } from '@/lib/github'
|
||||
*
|
||||
* // Get recent repos for configured user
|
||||
* const { username, repos } = await getRecentGitHubRepos()
|
||||
*
|
||||
* // Get featured projects with live metrics
|
||||
* const projects = await getFeaturedReposWithMetrics()
|
||||
* console.log(`${projects[0].name}: ${projects[0].stars} stars`)
|
||||
* ```
|
||||
*
|
||||
* @category Services
|
||||
* @module lib/github
|
||||
* @public
|
||||
*/
|
||||
|
||||
import { cache } from 'react'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { featuredRepos } from '@/lib/config/featured-repos'
|
||||
|
||||
/** Default GitHub username when not configured via environment variables */
|
||||
const DEFAULT_GITHUB_USER = 'ihatenodejs'
|
||||
|
||||
/** Default Forgejo instance URL */
|
||||
const DEFAULT_FORGEJO_URL = 'git.p0ntus.com'
|
||||
|
||||
/** Maximum number of recent repositories to fetch */
|
||||
const REPO_LIMIT = 4
|
||||
|
||||
/** Cache revalidation time in seconds (1 hour) */
|
||||
const REVALIDATE_SECONDS = 60 * 60
|
||||
|
||||
/** GitHub API repository response structure */
|
||||
interface GitHubRepoApi {
|
||||
id: number
|
||||
name: string
|
||||
html_url: string
|
||||
updated_at: string
|
||||
stargazers_count?: number
|
||||
forks_count?: number
|
||||
}
|
||||
|
||||
/** Forgejo API repository response structure */
|
||||
interface ForgejoRepoApi {
|
||||
id: number
|
||||
name: string
|
||||
html_url: string
|
||||
stars_count?: number
|
||||
forks_count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified repository summary for recent repos list.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface GitHubRepoSummary {
|
||||
/** Repository ID from GitHub API */
|
||||
id: number
|
||||
/** Repository name */
|
||||
name: string
|
||||
/** Full URL to repository */
|
||||
url: string
|
||||
/** ISO 8601 timestamp of last update */
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured project with live metrics from GitHub or Forgejo.
|
||||
*
|
||||
* @remarks
|
||||
* This interface represents a featured repository with real-time
|
||||
* star and fork counts fetched from the respective platform's API.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface FeaturedProject {
|
||||
/** Unique identifier for the project */
|
||||
id: number
|
||||
/** Repository owner username or organization */
|
||||
owner: string
|
||||
/** Repository name */
|
||||
repo: string
|
||||
/** Display name (may include owner prefix) */
|
||||
name: string
|
||||
/** Project description */
|
||||
description: string
|
||||
/** Source platform */
|
||||
platform: 'github' | 'forgejo'
|
||||
/** Full URL to repository */
|
||||
url: string
|
||||
/** Current star count */
|
||||
stars: number
|
||||
/** Current fork count */
|
||||
forks: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository engagement metrics.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface RepoMetrics {
|
||||
/** Number of stars/stargazers */
|
||||
stars: number
|
||||
/** Number of forks */
|
||||
forks: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves GitHub username from environment variables or uses default.
|
||||
*
|
||||
* @returns GitHub username to use for API requests
|
||||
* @internal
|
||||
*/
|
||||
const resolveConfiguredUser = (): string => {
|
||||
const configured = process.env.GITHUB_PROJECTS_USER ?? process.env.GITHUB_USERNAME
|
||||
const fallback = DEFAULT_GITHUB_USER
|
||||
|
||||
if (!configured) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const trimmed = configured.trim()
|
||||
return trimmed.length ? trimmed : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves GitHub PAT from environment variables for authentication.
|
||||
*
|
||||
* @returns Authorization header value or undefined if no token configured
|
||||
* @internal
|
||||
*/
|
||||
const resolveAuthHeader = (): string | undefined => {
|
||||
const token = process.env.GITHUB_PROJECTS_PAT ?? process.env.GITHUB_PAT
|
||||
|
||||
if (!token) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return `Bearer ${token.trim()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds HTTP headers for GitHub API requests.
|
||||
*
|
||||
* @remarks
|
||||
* Automatically includes Authorization header if PAT is configured.
|
||||
*
|
||||
* @returns Headers object for fetch requests
|
||||
* @internal
|
||||
*/
|
||||
const buildGitHubHeaders = (): Record<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'aidan.so',
|
||||
}
|
||||
|
||||
const authHeader = resolveAuthHeader()
|
||||
if (authHeader) {
|
||||
headers.Authorization = authHeader
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches recently updated repositories for a GitHub user.
|
||||
*
|
||||
* @param username - GitHub username to fetch repositories for
|
||||
* @returns Array of repository summaries, sorted by most recently updated
|
||||
*
|
||||
* @remarks
|
||||
* - Limited to 4 most recent repositories
|
||||
* - Uses Next.js fetch cache with 1-hour revalidation
|
||||
* - Returns empty array on error (logs to console)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const fetchRecentRepos = async (username: string): Promise<GitHubRepoSummary[]> => {
|
||||
const url = new URL(`https://api.github.com/users/${username}/repos`)
|
||||
url.searchParams.set('sort', 'updated')
|
||||
url.searchParams.set('per_page', REPO_LIMIT.toString())
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: buildGitHubHeaders(),
|
||||
next: {
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
tags: [`github-repos-${username}`],
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch GitHub repos for ${username}: ${response.status} ${response.statusText}`)
|
||||
return []
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GitHubRepoApi[]
|
||||
|
||||
return data.slice(0, REPO_LIMIT).map((repo) => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
url: repo.html_url,
|
||||
updatedAt: repo.updated_at,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error fetching GitHub repos for ${username}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side cached wrapper for fetchRecentRepos.
|
||||
*
|
||||
* @remarks
|
||||
* Uses Next.js unstable_cache to prevent API abuse across requests.
|
||||
* Cache is shared server-side and persists for 1 hour.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const getCachedRecentRepos = unstable_cache(
|
||||
async (username: string) => fetchRecentRepos(username),
|
||||
['github-recent-repos'],
|
||||
{
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
tags: ['github-repos'],
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Retrieves recently updated GitHub repositories for the configured user.
|
||||
*
|
||||
* @returns Object containing username and array of recent repositories
|
||||
*
|
||||
* @remarks
|
||||
* **Caching Strategy:**
|
||||
* - Server-side cache (unstable_cache): Shared across all requests, 1-hour TTL
|
||||
* - Request cache (React cache): Deduplicates calls within single request
|
||||
*
|
||||
* **Security:**
|
||||
* - Protects GitHub PAT from rate limit abuse
|
||||
* - Maximum 1 API call per hour regardless of page refreshes
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { username, repos } = await getRecentGitHubRepos()
|
||||
* repos.forEach(repo => console.log(`${repo.name}: ${repo.url}`))
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const getRecentGitHubRepos = cache(async () => {
|
||||
const username = resolveConfiguredUser()
|
||||
const repos = await getCachedRecentRepos(username)
|
||||
|
||||
return {
|
||||
username,
|
||||
repos,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Fetches star and fork counts for a specific GitHub repository.
|
||||
*
|
||||
* @param owner - Repository owner (user or organization)
|
||||
* @param repo - Repository name
|
||||
* @returns Repository metrics or null on error
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const fetchGitHubRepoMetrics = async (owner: string, repo: string): Promise<RepoMetrics | null> => {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: buildGitHubHeaders(),
|
||||
next: {
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
tags: [`github-repo-${owner}-${repo}`],
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch GitHub repo ${owner}/${repo}: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GitHubRepoApi
|
||||
|
||||
return {
|
||||
stars: data.stargazers_count ?? 0,
|
||||
forks: data.forks_count ?? 0,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching GitHub repo ${owner}/${repo}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side cached wrapper for GitHub repository metrics.
|
||||
*
|
||||
* @remarks
|
||||
* Prevents API abuse by caching metrics for 1 hour server-side.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const getCachedGitHubRepoMetrics = unstable_cache(
|
||||
async (owner: string, repo: string) => fetchGitHubRepoMetrics(owner, repo),
|
||||
['github-repo-metrics'],
|
||||
{
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
tags: ['github-metrics'],
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches star and fork counts for a Forgejo repository.
|
||||
*
|
||||
* @param owner - Repository owner
|
||||
* @param repo - Repository name
|
||||
* @param forgejoBaseUrl - Forgejo instance base URL (default: git.p0ntus.com)
|
||||
* @returns Repository metrics or null on error
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const fetchForgejoRepoMetrics = async (
|
||||
owner: string,
|
||||
repo: string,
|
||||
forgejoBaseUrl: string = DEFAULT_FORGEJO_URL
|
||||
): Promise<RepoMetrics | null> => {
|
||||
try {
|
||||
const apiUrl = `https://${forgejoBaseUrl}/api/v1/repos/${owner}/${repo}`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'aidan.so',
|
||||
},
|
||||
next: {
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
tags: [`forgejo-repo-${forgejoBaseUrl}-${owner}-${repo}`],
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch Forgejo repo ${owner}/${repo} from ${forgejoBaseUrl}: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ForgejoRepoApi
|
||||
|
||||
return {
|
||||
stars: data.stars_count ?? 0,
|
||||
forks: data.forks_count ?? 0,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Forgejo repo ${owner}/${repo} from ${forgejoBaseUrl}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side cached wrapper for Forgejo repository metrics.
|
||||
*
|
||||
* @remarks
|
||||
* Prevents API abuse by caching metrics for 1 hour server-side.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const getCachedForgejoRepoMetrics = unstable_cache(
|
||||
async (owner: string, repo: string, forgejoBaseUrl: string = DEFAULT_FORGEJO_URL) =>
|
||||
fetchForgejoRepoMetrics(owner, repo, forgejoBaseUrl),
|
||||
['forgejo-repo-metrics'],
|
||||
{
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
tags: ['forgejo-metrics'],
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches all featured projects with live metrics from their platforms.
|
||||
*
|
||||
* @returns Array of featured projects with current star/fork counts
|
||||
*
|
||||
* @remarks
|
||||
* Automatically fetches from the correct API (GitHub or Forgejo) based on
|
||||
* platform configuration in featured-repos.ts.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
const fetchFeaturedProjects = async (): Promise<FeaturedProject[]> => {
|
||||
const projects = await Promise.all(
|
||||
featuredRepos.map(async (config) => {
|
||||
let metrics: RepoMetrics | null = null
|
||||
|
||||
if (config.platform === 'github') {
|
||||
metrics = await getCachedGitHubRepoMetrics(config.owner, config.repo)
|
||||
} else if (config.platform === 'forgejo') {
|
||||
metrics = await getCachedForgejoRepoMetrics(
|
||||
config.owner,
|
||||
config.repo,
|
||||
config.forgejoUrl ?? DEFAULT_FORGEJO_URL
|
||||
)
|
||||
}
|
||||
|
||||
const url = config.platform === 'github'
|
||||
? `https://github.com/${config.owner}/${config.repo}`
|
||||
: `https://${config.forgejoUrl ?? DEFAULT_FORGEJO_URL}/${config.owner}/${config.repo}`
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
owner: config.owner,
|
||||
repo: config.repo,
|
||||
name: config.owner === config.repo ? config.repo : `${config.owner}/${config.repo}`,
|
||||
description: config.description,
|
||||
platform: config.platform,
|
||||
url,
|
||||
stars: metrics?.stars ?? 0,
|
||||
forks: metrics?.forks ?? 0,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return projects
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves featured projects with live star and fork counts.
|
||||
*
|
||||
* @returns Array of featured projects with real-time metrics
|
||||
*
|
||||
* @remarks
|
||||
* **Data Source:**
|
||||
* Projects are configured in `lib/config/featured-repos.ts`.
|
||||
* Metrics are fetched live from GitHub and Forgejo APIs.
|
||||
*
|
||||
* **Caching Strategy:**
|
||||
* - Server-side cache (unstable_cache): Shared across all requests, 1-hour TTL
|
||||
* - Request cache (React cache): Deduplicates calls within single request
|
||||
*
|
||||
* **Security:**
|
||||
* - Prevents API abuse through dual-layer caching
|
||||
* - Maximum 1 API call per repository per hour
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const projects = await getFeaturedReposWithMetrics()
|
||||
*
|
||||
* projects.forEach(project => {
|
||||
* console.log(`${project.name}: ${project.stars} ⭐ ${project.forks} 🍴`)
|
||||
* console.log(`Platform: ${project.platform}`)
|
||||
* console.log(`URL: ${project.url}`)
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const getFeaturedReposWithMetrics = cache(
|
||||
unstable_cache(
|
||||
async () => fetchFeaturedProjects(),
|
||||
['featured-repos'],
|
||||
{
|
||||
revalidate: REVALIDATE_SECONDS,
|
||||
tags: ['featured-projects'],
|
||||
}
|
||||
)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue