/ src / content / loaders / github.ts
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  }