@rmdes/indiekit-endpoint-github 1.1.0 → 1.2.0
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.
- package/lib/controllers/starred.js +3 -2
- package/lib/github-graphql.js +157 -0
- package/lib/starred-cache.js +43 -0
- package/lib/starred-sync.js +46 -5
- package/package.json +1 -1
|
@@ -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
|
|
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);
|
package/lib/github-graphql.js
CHANGED
|
@@ -36,6 +36,49 @@ 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
|
+
items(first: 100) {
|
|
50
|
+
totalCount
|
|
51
|
+
nodes {
|
|
52
|
+
... on Repository {
|
|
53
|
+
nameWithOwner
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
pageInfo { endCursor hasNextPage }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
pageInfo { endCursor hasNextPage }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const LIST_ITEMS_QUERY = `
|
|
66
|
+
query($slug: String!, $cursor: String) {
|
|
67
|
+
viewer {
|
|
68
|
+
list(slug: $slug) {
|
|
69
|
+
items(first: 100, after: $cursor) {
|
|
70
|
+
nodes {
|
|
71
|
+
... on Repository {
|
|
72
|
+
nameWithOwner
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
pageInfo { endCursor hasNextPage }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
|
|
39
82
|
/**
|
|
40
83
|
* Format a single starred repo edge from GraphQL response
|
|
41
84
|
* @param {object} edge - GraphQL edge with starredAt + node
|
|
@@ -129,3 +172,117 @@ export async function fetchAllStarred(token, options = {}) {
|
|
|
129
172
|
|
|
130
173
|
return { stars: allStars, totalCount };
|
|
131
174
|
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Fetch all GitHub Lists and their repository memberships
|
|
178
|
+
* @param {string} token - GitHub personal access token (REQUIRED)
|
|
179
|
+
* @returns {Promise<Array<{name: string, slug: string, description: string, repoFullNames: string[]}>>}
|
|
180
|
+
*/
|
|
181
|
+
export async function fetchAllLists(token) {
|
|
182
|
+
if (!token) {
|
|
183
|
+
throw new Error("GitHub token is required for GraphQL API");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const allLists = [];
|
|
187
|
+
let cursor = null;
|
|
188
|
+
let hasNextPage = true;
|
|
189
|
+
|
|
190
|
+
// Fetch all lists (outer pagination)
|
|
191
|
+
while (hasNextPage) {
|
|
192
|
+
const response = await fetch(GITHUB_GRAPHQL_URL, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: {
|
|
195
|
+
Authorization: `Bearer ${token}`,
|
|
196
|
+
"Content-Type": "application/json",
|
|
197
|
+
},
|
|
198
|
+
body: JSON.stringify({
|
|
199
|
+
query: LISTS_QUERY,
|
|
200
|
+
variables: { cursor },
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const body = await response.json();
|
|
209
|
+
|
|
210
|
+
if (body.errors) {
|
|
211
|
+
throw new Error(`GraphQL errors: ${body.errors.map((e) => e.message).join(", ")}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const listsData = body.data.viewer.lists;
|
|
215
|
+
|
|
216
|
+
for (const node of listsData.nodes) {
|
|
217
|
+
// Skip private lists
|
|
218
|
+
if (node.isPrivate) continue;
|
|
219
|
+
|
|
220
|
+
const repoFullNames = (node.items.nodes || [])
|
|
221
|
+
.map((n) => n.nameWithOwner)
|
|
222
|
+
.filter(Boolean);
|
|
223
|
+
|
|
224
|
+
const list = {
|
|
225
|
+
name: node.name,
|
|
226
|
+
slug: node.slug,
|
|
227
|
+
description: node.description || "",
|
|
228
|
+
repoFullNames,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// If this list has more items than the first page, paginate them
|
|
232
|
+
if (node.items.pageInfo.hasNextPage) {
|
|
233
|
+
let itemsCursor = node.items.pageInfo.endCursor;
|
|
234
|
+
let itemsHasNext = true;
|
|
235
|
+
|
|
236
|
+
while (itemsHasNext) {
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
238
|
+
|
|
239
|
+
const itemsResponse = await fetch(GITHUB_GRAPHQL_URL, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: {
|
|
242
|
+
Authorization: `Bearer ${token}`,
|
|
243
|
+
"Content-Type": "application/json",
|
|
244
|
+
},
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
query: LIST_ITEMS_QUERY,
|
|
247
|
+
variables: { slug: node.slug, cursor: itemsCursor },
|
|
248
|
+
}),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (!itemsResponse.ok) {
|
|
252
|
+
console.error(`[GitHub Lists] Failed to paginate items for list "${node.name}": ${itemsResponse.status}`);
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const itemsBody = await itemsResponse.json();
|
|
257
|
+
|
|
258
|
+
if (itemsBody.errors) {
|
|
259
|
+
console.error(`[GitHub Lists] GraphQL errors for list "${node.name}": ${itemsBody.errors.map((e) => e.message).join(", ")}`);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const itemsData = itemsBody.data.viewer.list.items;
|
|
264
|
+
for (const itemNode of itemsData.nodes) {
|
|
265
|
+
if (itemNode.nameWithOwner) {
|
|
266
|
+
list.repoFullNames.push(itemNode.nameWithOwner);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
itemsCursor = itemsData.pageInfo.endCursor;
|
|
271
|
+
itemsHasNext = itemsData.pageInfo.hasNextPage;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
allLists.push(list);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
cursor = listsData.pageInfo.endCursor;
|
|
279
|
+
hasNextPage = listsData.pageInfo.hasNextPage;
|
|
280
|
+
|
|
281
|
+
if (hasNextPage) {
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.log(`[GitHub Lists] Fetched ${allLists.length} public lists`);
|
|
287
|
+
return allLists;
|
|
288
|
+
}
|
package/lib/starred-cache.js
CHANGED
|
@@ -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
|
package/lib/starred-sync.js
CHANGED
|
@@ -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
|
-
|
|
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