@rmdes/indiekit-endpoint-github 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@ import {
18
18
  export const starredController = {
19
19
  /**
20
20
  * GET /api/starred/all — Public JSON API
21
- * Returns all cached starred repos for Eleventy build
21
+ * Returns all cached starred repos with list metadata
22
22
  */
23
23
  async all(request, response, next) {
24
24
  try {
@@ -26,7 +26,7 @@ export const starredController = {
26
26
  const db = application.getGithubDb();
27
27
 
28
28
  if (!db) {
29
- return response.json({ stars: [], totalCount: 0, lastSync: null });
29
+ return response.json({ stars: [], totalCount: 0, lastSync: null, listMeta: [] });
30
30
  }
31
31
 
32
32
  const collection = db.collection("github_stars");
@@ -40,6 +40,7 @@ export const starredController = {
40
40
  stars,
41
41
  totalCount,
42
42
  lastSync: syncState.lastIncrementalSync || syncState.lastFullSync || null,
43
+ listMeta: syncState.lists || [],
43
44
  });
44
45
  } catch (error) {
45
46
  next(error);
@@ -36,6 +36,40 @@ const STARRED_QUERY = `
36
36
  }
37
37
  `;
38
38
 
39
+ const LISTS_QUERY = `
40
+ query($cursor: String) {
41
+ viewer {
42
+ lists(first: 100, after: $cursor) {
43
+ totalCount
44
+ nodes {
45
+ name
46
+ slug
47
+ description
48
+ isPrivate
49
+ }
50
+ pageInfo { endCursor hasNextPage }
51
+ }
52
+ }
53
+ }
54
+ `;
55
+
56
+ const LIST_ITEMS_QUERY = `
57
+ query($slug: String!, $cursor: String) {
58
+ viewer {
59
+ list(slug: $slug) {
60
+ items(first: 100, after: $cursor) {
61
+ nodes {
62
+ ... on Repository {
63
+ nameWithOwner
64
+ }
65
+ }
66
+ pageInfo { endCursor hasNextPage }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ `;
72
+
39
73
  /**
40
74
  * Format a single starred repo edge from GraphQL response
41
75
  * @param {object} edge - GraphQL edge with starredAt + node
@@ -129,3 +163,118 @@ export async function fetchAllStarred(token, options = {}) {
129
163
 
130
164
  return { stars: allStars, totalCount };
131
165
  }
166
+
167
+ /**
168
+ * Fetch all GitHub Lists and their repository memberships
169
+ * @param {string} token - GitHub personal access token (REQUIRED)
170
+ * @returns {Promise<Array<{name: string, slug: string, description: string, repoFullNames: string[]}>>}
171
+ */
172
+ export async function fetchAllLists(token) {
173
+ if (!token) {
174
+ throw new Error("GitHub token is required for GraphQL API");
175
+ }
176
+
177
+ // Stage 1: Fetch list metadata only (no items — avoids timeout)
178
+ const listMeta = [];
179
+ let cursor = null;
180
+ let hasNextPage = true;
181
+
182
+ while (hasNextPage) {
183
+ const response = await fetch(GITHUB_GRAPHQL_URL, {
184
+ method: "POST",
185
+ headers: {
186
+ Authorization: `Bearer ${token}`,
187
+ "Content-Type": "application/json",
188
+ },
189
+ body: JSON.stringify({
190
+ query: LISTS_QUERY,
191
+ variables: { cursor },
192
+ }),
193
+ });
194
+
195
+ if (!response.ok) {
196
+ throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
197
+ }
198
+
199
+ const body = await response.json();
200
+
201
+ if (body.errors) {
202
+ throw new Error(`GraphQL errors: ${body.errors.map((e) => e.message).join(", ")}`);
203
+ }
204
+
205
+ const listsData = body.data.viewer.lists;
206
+
207
+ for (const node of listsData.nodes) {
208
+ if (node.isPrivate) continue;
209
+ listMeta.push({
210
+ name: node.name,
211
+ slug: node.slug,
212
+ description: node.description || "",
213
+ });
214
+ }
215
+
216
+ cursor = listsData.pageInfo.endCursor;
217
+ hasNextPage = listsData.pageInfo.hasNextPage;
218
+
219
+ if (hasNextPage) {
220
+ await new Promise((resolve) => setTimeout(resolve, 200));
221
+ }
222
+ }
223
+
224
+ // Stage 2: Fetch items per list individually (avoids timeout from bulk query)
225
+ const allLists = [];
226
+
227
+ for (const meta of listMeta) {
228
+ const repoFullNames = [];
229
+ let itemsCursor = null;
230
+ let itemsHasNext = true;
231
+
232
+ while (itemsHasNext) {
233
+ await new Promise((resolve) => setTimeout(resolve, 200));
234
+
235
+ const itemsResponse = await fetch(GITHUB_GRAPHQL_URL, {
236
+ method: "POST",
237
+ headers: {
238
+ Authorization: `Bearer ${token}`,
239
+ "Content-Type": "application/json",
240
+ },
241
+ body: JSON.stringify({
242
+ query: LIST_ITEMS_QUERY,
243
+ variables: { slug: meta.slug, cursor: itemsCursor },
244
+ }),
245
+ });
246
+
247
+ if (!itemsResponse.ok) {
248
+ console.error(`[GitHub Lists] Failed to fetch items for list "${meta.name}": ${itemsResponse.status}`);
249
+ break;
250
+ }
251
+
252
+ const itemsBody = await itemsResponse.json();
253
+
254
+ if (itemsBody.errors) {
255
+ console.error(`[GitHub Lists] GraphQL errors for list "${meta.name}": ${itemsBody.errors.map((e) => e.message).join(", ")}`);
256
+ break;
257
+ }
258
+
259
+ const itemsData = itemsBody.data.viewer.list.items;
260
+ for (const itemNode of itemsData.nodes) {
261
+ if (itemNode.nameWithOwner) {
262
+ repoFullNames.push(itemNode.nameWithOwner);
263
+ }
264
+ }
265
+
266
+ itemsCursor = itemsData.pageInfo.endCursor;
267
+ itemsHasNext = itemsData.pageInfo.hasNextPage;
268
+ }
269
+
270
+ allLists.push({
271
+ name: meta.name,
272
+ slug: meta.slug,
273
+ description: meta.description,
274
+ repoFullNames,
275
+ });
276
+ }
277
+
278
+ console.log(`[GitHub Lists] Fetched ${allLists.length} public lists`);
279
+ return allLists;
280
+ }
@@ -12,6 +12,7 @@ export async function ensureIndexes(collection) {
12
12
  await collection.createIndex({ fullName: 1 }, { unique: true });
13
13
  await collection.createIndex({ starredAt: -1 });
14
14
  await collection.createIndex({ language: 1 });
15
+ await collection.createIndex({ lists: 1 });
15
16
  }
16
17
 
17
18
  /**
@@ -65,6 +66,48 @@ export async function removeUnstarred(collection, currentFullNames) {
65
66
  return result.deletedCount;
66
67
  }
67
68
 
69
+ /**
70
+ * Annotate starred repos with their GitHub List memberships
71
+ * Builds a fullName→slugs map in memory, then uses ordered bulkWrite
72
+ * to minimize the race window where concurrent reads see empty lists
73
+ * @param {import("mongodb").Db} db
74
+ * @param {Array<{slug: string, repoFullNames: string[]}>} lists
75
+ * @returns {Promise<{listsProcessed: number, reposAnnotated: number}>}
76
+ */
77
+ export async function annotateWithLists(db, lists) {
78
+ const collection = db.collection("github_stars");
79
+
80
+ // Build fullName → slugs map in memory
81
+ const repoToLists = new Map();
82
+ for (const list of lists) {
83
+ for (const fullName of list.repoFullNames) {
84
+ if (!repoToLists.has(fullName)) repoToLists.set(fullName, []);
85
+ repoToLists.get(fullName).push(list.slug);
86
+ }
87
+ }
88
+
89
+ // Single ordered bulkWrite: reset all, then set per-repo lists
90
+ const ops = [
91
+ { updateMany: { filter: {}, update: { $set: { lists: [] } } } },
92
+ ...[...repoToLists.entries()].map(([fullName, slugs]) => ({
93
+ updateOne: {
94
+ filter: { fullName },
95
+ update: { $set: { lists: slugs } },
96
+ },
97
+ })),
98
+ ];
99
+
100
+ // Process in batches of 1000 to avoid MongoDB limits
101
+ let reposAnnotated = 0;
102
+ for (let i = 0; i < ops.length; i += 1000) {
103
+ const batch = ops.slice(i, i + 1000);
104
+ const result = await collection.bulkWrite(batch, { ordered: true });
105
+ reposAnnotated += result.modifiedCount;
106
+ }
107
+
108
+ return { listsProcessed: lists.length, reposAnnotated };
109
+ }
110
+
68
111
  /**
69
112
  * Get all starred repos sorted by starredAt descending
70
113
  * @param {import("mongodb").Collection} collection
@@ -4,11 +4,12 @@
4
4
  * @module starred-sync
5
5
  */
6
6
 
7
- import { fetchAllStarred } from "./github-graphql.js";
7
+ import { fetchAllStarred, fetchAllLists } from "./github-graphql.js";
8
8
  import {
9
9
  ensureIndexes,
10
10
  syncStars,
11
11
  removeUnstarred,
12
+ annotateWithLists,
12
13
  getStarCount,
13
14
  getSyncState,
14
15
  updateSyncState,
@@ -35,6 +36,37 @@ export function getStarredSyncStatus() {
35
36
  return { ...syncStatus };
36
37
  }
37
38
 
39
+ /**
40
+ * Fetch GitHub Lists and annotate starred repos
41
+ * @param {import("mongodb").Db} db
42
+ * @param {object} options - Plugin options (needs token)
43
+ */
44
+ async function syncLists(db, options) {
45
+ try {
46
+ const lists = await fetchAllLists(options.token);
47
+
48
+ if (lists.length > 0) {
49
+ const result = await annotateWithLists(db, lists);
50
+ console.log(
51
+ `[GitHub Stars] Lists sync: ${result.listsProcessed} lists, ${result.reposAnnotated} repos annotated`,
52
+ );
53
+ }
54
+
55
+ // Store list metadata in sync state
56
+ const listMeta = lists.map((l) => ({
57
+ name: l.name,
58
+ slug: l.slug,
59
+ description: l.description,
60
+ count: l.repoFullNames.length,
61
+ }));
62
+
63
+ await updateSyncState(db, { lists: listMeta });
64
+ } catch (error) {
65
+ console.error("[GitHub Stars] Lists sync failed:", error.message);
66
+ // Non-fatal — starred data is still usable without lists
67
+ }
68
+ }
69
+
38
70
  /**
39
71
  * Start the background sync scheduler
40
72
  * @param {object} Indiekit - Indiekit instance
@@ -68,16 +100,22 @@ export function startStarredSync(Indiekit, options) {
68
100
  console.log(`[GitHub Stars] Cache has ${count} stars, running incremental sync...`);
69
101
  await runIncrementalSync(db, options);
70
102
  }
103
+
104
+ // Sync lists after initial star sync
105
+ await syncLists(db, options);
71
106
  } catch (error) {
72
107
  console.error("[GitHub Stars] Initial sync error:", error.message);
73
108
  }
74
109
  }, 15_000);
75
110
 
76
- // Incremental sync every 6 hours
77
- incrementalTimer = setInterval(() => {
78
- runIncrementalSync(db, options).catch((err) => {
111
+ // Incremental sync every 6 hours (also re-annotate lists for new stars)
112
+ incrementalTimer = setInterval(async () => {
113
+ try {
114
+ await runIncrementalSync(db, options);
115
+ await syncLists(db, options);
116
+ } catch (err) {
79
117
  console.error("[GitHub Stars] Incremental sync error:", err.message);
80
- });
118
+ }
81
119
  }, SIX_HOURS);
82
120
 
83
121
  // Full sync every 7 days
@@ -189,6 +227,9 @@ export async function runFullSync(db, options) {
189
227
  totalStars,
190
228
  });
191
229
 
230
+ // Sync lists during full sync
231
+ await syncLists(db, options);
232
+
192
233
  console.log(
193
234
  `[GitHub Stars] Full sync complete: ${result.upserted} new, ${result.modified} updated, ${removed} removed, ${totalStars} total (GitHub reports ${totalCount})`,
194
235
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-github",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "GitHub activity endpoint for Indiekit. Display commits, stars, contributions, and featured repositories.",
5
5
  "keywords": [
6
6
  "indiekit",