github.ts
1 import { z } from "astro:content"; 2 import { GH_TOKEN } from "astro:env/server"; 3 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 4 import { join } from "node:path"; 5 import type { Loader } from "astro/loaders"; 6 import { Octokit } from "octokit"; 7 import type { 8 GitHubLoaderOptions, 9 GitHubPullRequest, 10 GraphQLPullRequest, 11 GraphQLResponse, 12 } from "../../types/github-pr"; 13 14 const octokit = new Octokit({ auth: GH_TOKEN }); 15 16 const CACHE_DIR = join(process.cwd(), ".astro", "cache"); 17 const CACHE_FILE = join(CACHE_DIR, "github-prs.json"); 18 const CACHE_TTL_MS = 1000 * 60 * 60; // 1 hour 19 20 interface CacheData { 21 timestamp: number; 22 data: GitHubPullRequest[]; 23 } 24 25 function loadFromCache(): GitHubPullRequest[] | null { 26 if (!existsSync(CACHE_FILE)) return null; 27 28 try { 29 const cache: CacheData = JSON.parse(readFileSync(CACHE_FILE, "utf-8")); 30 const age = Date.now() - cache.timestamp; 31 32 if (age > CACHE_TTL_MS) return null; 33 34 return cache.data; 35 } catch { 36 return null; 37 } 38 } 39 40 function saveToCache(data: GitHubPullRequest[]) { 41 if (!existsSync(CACHE_DIR)) { 42 mkdirSync(CACHE_DIR, { recursive: true }); 43 } 44 45 const cacheData: CacheData = { 46 timestamp: Date.now(), 47 data, 48 }; 49 50 writeFileSync(CACHE_FILE, JSON.stringify(cacheData, null, 2)); 51 } 52 53 const GET_PRS_QUERY = ` 54 query GetPRs($username: String!, $endCursor: String) { 55 user(login: $username) { 56 pullRequests( 57 first: 100 58 after: $endCursor 59 states: MERGED 60 orderBy: { field: CREATED_AT, direction: DESC } 61 ) { 62 pageInfo { 63 hasNextPage 64 endCursor 65 } 66 nodes { 67 id 68 number 69 title 70 state 71 mergedAt 72 createdAt 73 updatedAt 74 url 75 repository { 76 name 77 nameWithOwner 78 url 79 stargazerCount 80 isArchived 81 } 82 author { 83 login 84 url 85 } 86 additions 87 deletions 88 changedFiles 89 } 90 } 91 } 92 } 93 `; 94 95 async function* getAllMergedPullRequests( 96 username: string, 97 ): AsyncGenerator<GraphQLPullRequest, void, void> { 98 let endCursor: string | null = null; 99 let hasNextPage = true; 100 101 while (hasNextPage) { 102 const response = (await octokit.graphql<GraphQLResponse>(GET_PRS_QUERY, { 103 username, 104 endCursor, 105 })) as GraphQLResponse; 106 107 const { pullRequests } = response.user; 108 109 for (const pr of pullRequests.nodes) { 110 yield pr; 111 } 112 113 hasNextPage = pullRequests.pageInfo.hasNextPage; 114 endCursor = pullRequests.pageInfo.endCursor; 115 } 116 } 117 118 export function githubLoader(options: GitHubLoaderOptions): Loader { 119 return { 120 name: "github", 121 load: async ({ store, logger, parseData, generateDigest }) => { 122 const isDev = import.meta.env.DEV; 123 124 logger.info(`Loading GitHub PRs for user: ${options.username}`); 125 126 // In dev mode, try to load from cache first 127 if (isDev) { 128 const cached = loadFromCache(); 129 if (cached) { 130 logger.info( 131 `Loaded ${cached.length} GitHub PRs from cache for user: ${options.username}`, 132 ); 133 for (const pr of cached) { 134 const data = await parseData({ 135 id: `${pr.repository.name}-${pr.number}`, 136 data: pr as unknown as Record<string, unknown>, 137 }); 138 const digest = generateDigest(data); 139 store.set({ 140 id: `${pr.repository.name}-${pr.number}`, 141 data, 142 digest, 143 }); 144 } 145 return; 146 } 147 logger.info("Cache miss or expired, fetching from GitHub API"); 148 } 149 150 try { 151 store.clear(); 152 153 const allPRs: GitHubPullRequest[] = []; 154 155 for await (const pr of getAllMergedPullRequests(options.username)) { 156 if ( 157 pr.repository.stargazerCount < options.minStars || 158 pr.repository.isArchived 159 ) { 160 continue; 161 } 162 163 const normalized: GitHubPullRequest = { 164 id: pr.id, 165 number: pr.number, 166 title: pr.title, 167 state: pr.state.toLowerCase() as "open" | "closed" | "merged", 168 merged_at: pr.mergedAt, 169 created_at: pr.createdAt, 170 updated_at: pr.updatedAt, 171 url: pr.url, 172 repository: { 173 name: pr.repository.name, 174 full_name: pr.repository.nameWithOwner, 175 url: pr.repository.url, 176 stargazerCount: pr.repository.stargazerCount, 177 }, 178 user: { 179 login: pr.author.login, 180 url: pr.author.url, 181 }, 182 additions: pr.additions, 183 deletions: pr.deletions, 184 changed_files: pr.changedFiles, 185 }; 186 187 allPRs.push(normalized); 188 189 const data = await parseData({ 190 id: `${normalized.repository.name}-${normalized.number}`, 191 data: normalized as unknown as Record<string, unknown>, 192 }); 193 const digest = generateDigest(data); 194 store.set({ 195 id: `${normalized.repository.name}-${normalized.number}`, 196 data, 197 digest, 198 }); 199 } 200 201 logger.info( 202 `Loaded ${allPRs.length} GitHub PRs for user: ${options.username}`, 203 ); 204 205 // Save to cache in dev mode 206 if (isDev) { 207 saveToCache(allPRs); 208 } 209 } catch (error) { 210 logger.error(`Error loading GitHub PRs: ${error}`); 211 } 212 }, 213 schema: z.object({ 214 id: z.string(), 215 number: z.number(), 216 title: z.string(), 217 state: z.enum(["open", "closed", "merged"]), 218 merged_at: z.string().nullable(), 219 created_at: z.string(), 220 updated_at: z.string(), 221 url: z.string().url(), 222 repository: z.object({ 223 name: z.string(), 224 full_name: z.string(), 225 url: z.string().url(), 226 stargazerCount: z.number(), 227 }), 228 user: z.object({ 229 login: z.string(), 230 url: z.string().url(), 231 }), 232 additions: z.number(), 233 deletions: z.number(), 234 changed_files: z.number(), 235 }), 236 }; 237 }