@rmdes/indiekit-endpoint-github 1.0.7 → 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/index.js CHANGED
@@ -9,6 +9,7 @@ import { contributionsController } from "./lib/controllers/contributions.js";
9
9
  import { dashboardController } from "./lib/controllers/dashboard.js";
10
10
  import { featuredController } from "./lib/controllers/featured.js";
11
11
  import { starsController } from "./lib/controllers/stars.js";
12
+ import { starredController } from "./lib/controllers/starred.js";
12
13
 
13
14
  // Module-level routers (matching Indiekit's endpoint pattern)
14
15
  const protectedRouter = express.Router();
@@ -77,6 +78,7 @@ export default class GitHubEndpoint {
77
78
  protectedRouter.get("/contributions", contributionsController.get);
78
79
  protectedRouter.get("/activity", activityController.get);
79
80
  protectedRouter.get("/featured", featuredController.get);
81
+ protectedRouter.get("/starred/sync", starredController.sync);
80
82
 
81
83
  return protectedRouter;
82
84
  }
@@ -93,6 +95,8 @@ export default class GitHubEndpoint {
93
95
  publicRouter.get("/api/activity", activityController.api);
94
96
  publicRouter.get("/api/featured", featuredController.api);
95
97
  publicRouter.get("/api/changelog", changelogController.api);
98
+ publicRouter.get("/api/starred/all", starredController.all);
99
+ publicRouter.get("/api/starred/recent", starredController.recent);
96
100
 
97
101
  return publicRouter;
98
102
  }
@@ -100,8 +104,24 @@ export default class GitHubEndpoint {
100
104
  init(Indiekit) {
101
105
  Indiekit.addEndpoint(this);
102
106
 
103
- // Store GitHub config in application for controller access
107
+ // Register MongoDB collections for starred cache
108
+ Indiekit.addCollection("github_stars");
109
+ Indiekit.addCollection("github_sync_state");
110
+
111
+ // Store config and DB getter for controller access
104
112
  Indiekit.config.application.githubConfig = this.options;
105
113
  Indiekit.config.application.githubEndpoint = this.mountPath;
114
+ Indiekit.config.application.getGithubDb = () => Indiekit.database;
115
+
116
+ // Start background sync for starred repos (if token + DB available)
117
+ if (this.options.token && Indiekit.database) {
118
+ import("./lib/starred-sync.js")
119
+ .then(({ startStarredSync }) => {
120
+ startStarredSync(Indiekit, this.options);
121
+ })
122
+ .catch((error) => {
123
+ console.error("[GitHub Stars] Sync scheduler failed to start:", error.message);
124
+ });
125
+ }
106
126
  }
107
127
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Starred repositories API controller
3
+ * Serves cached data from MongoDB for Eleventy build + live updates
4
+ */
5
+
6
+ import {
7
+ getAllStars,
8
+ getRecentStars,
9
+ getStarCount,
10
+ getSyncState,
11
+ } from "../starred-cache.js";
12
+ import {
13
+ runIncrementalSync,
14
+ runFullSync,
15
+ getStarredSyncStatus,
16
+ } from "../starred-sync.js";
17
+
18
+ export const starredController = {
19
+ /**
20
+ * GET /api/starred/all — Public JSON API
21
+ * Returns all cached starred repos with list metadata
22
+ */
23
+ async all(request, response, next) {
24
+ try {
25
+ const { application } = request.app.locals;
26
+ const db = application.getGithubDb();
27
+
28
+ if (!db) {
29
+ return response.json({ stars: [], totalCount: 0, lastSync: null, listMeta: [] });
30
+ }
31
+
32
+ const collection = db.collection("github_stars");
33
+ const [stars, totalCount, syncState] = await Promise.all([
34
+ getAllStars(collection),
35
+ getStarCount(collection),
36
+ getSyncState(db),
37
+ ]);
38
+
39
+ response.json({
40
+ stars,
41
+ totalCount,
42
+ lastSync: syncState.lastIncrementalSync || syncState.lastFullSync || null,
43
+ listMeta: syncState.lists || [],
44
+ });
45
+ } catch (error) {
46
+ next(error);
47
+ }
48
+ },
49
+
50
+ /**
51
+ * GET /api/starred/recent?since=ISO — Public JSON API
52
+ * Returns stars newer than the given timestamp (for live updates)
53
+ */
54
+ async recent(request, response, next) {
55
+ try {
56
+ const { application } = request.app.locals;
57
+ const db = application.getGithubDb();
58
+
59
+ const since = request.query.since;
60
+ if (!since) {
61
+ return response.status(400).json({ error: "Missing 'since' query parameter (ISO date)" });
62
+ }
63
+
64
+ if (!db) {
65
+ return response.json({ stars: [], totalCount: 0 });
66
+ }
67
+
68
+ const collection = db.collection("github_stars");
69
+ const [stars, totalCount] = await Promise.all([
70
+ getRecentStars(collection, since),
71
+ getStarCount(collection),
72
+ ]);
73
+
74
+ response.json({ stars, totalCount });
75
+ } catch (error) {
76
+ next(error);
77
+ }
78
+ },
79
+
80
+ /**
81
+ * GET /api/starred/sync — Protected, triggers manual sync
82
+ * Query param: ?full=true for full re-sync
83
+ */
84
+ async sync(request, response, next) {
85
+ try {
86
+ const { application } = request.app.locals;
87
+ const db = application.getGithubDb();
88
+ const config = application.githubConfig;
89
+
90
+ if (!db) {
91
+ return response.status(503).json({ error: "Database not available" });
92
+ }
93
+
94
+ if (!config.token) {
95
+ return response.status(400).json({ error: "No GitHub token configured" });
96
+ }
97
+
98
+ const status = getStarredSyncStatus();
99
+ if (status.syncing) {
100
+ return response.status(409).json({ error: "Sync already in progress" });
101
+ }
102
+
103
+ const isFull = request.query.full === "true";
104
+
105
+ // Run sync in background, respond immediately
106
+ const syncFn = isFull ? runFullSync : runIncrementalSync;
107
+ syncFn(db, config).catch((err) => {
108
+ console.error("[GitHub Stars] Manual sync error:", err.message);
109
+ });
110
+
111
+ response.json({
112
+ message: `${isFull ? "Full" : "Incremental"} sync started`,
113
+ currentStatus: getStarredSyncStatus(),
114
+ });
115
+ } catch (error) {
116
+ next(error);
117
+ }
118
+ },
119
+ };
@@ -40,7 +40,17 @@ export class GitHubClient {
40
40
  const response = await fetch(url, { headers });
41
41
 
42
42
  if (!response.ok) {
43
- throw await IndiekitError.fromFetch(response);
43
+ // Only use fromFetch for JSON error responses; GitHub sometimes returns
44
+ // HTML error pages (e.g., 502 Bad Gateway) which cause SyntaxError noise
45
+ const contentType = response.headers.get("content-type") || "";
46
+ if (contentType.includes("json")) {
47
+ throw await IndiekitError.fromFetch(response);
48
+ }
49
+
50
+ throw new IndiekitError(response.statusText, {
51
+ status: response.status,
52
+ code: response.statusText,
53
+ });
44
54
  }
45
55
 
46
56
  const data = await response.json();
@@ -0,0 +1,288 @@
1
+ /**
2
+ * GitHub GraphQL API client for fetching all starred repositories
3
+ * Uses cursor-based pagination to fetch 100 repos per request
4
+ * @module github-graphql
5
+ */
6
+
7
+ const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql";
8
+
9
+ const STARRED_QUERY = `
10
+ query($cursor: String) {
11
+ viewer {
12
+ starredRepositories(first: 100, after: $cursor, orderBy: {field: STARRED_AT, direction: DESC}) {
13
+ totalCount
14
+ edges {
15
+ starredAt
16
+ node {
17
+ nameWithOwner
18
+ name
19
+ description
20
+ url
21
+ primaryLanguage { name }
22
+ stargazerCount
23
+ forkCount
24
+ pushedAt
25
+ isArchived
26
+ owner { avatarUrl login }
27
+ licenseInfo { spdxId name }
28
+ repositoryTopics(first: 10) {
29
+ nodes { topic { name } }
30
+ }
31
+ }
32
+ }
33
+ pageInfo { endCursor hasNextPage }
34
+ }
35
+ }
36
+ }
37
+ `;
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
+
82
+ /**
83
+ * Format a single starred repo edge from GraphQL response
84
+ * @param {object} edge - GraphQL edge with starredAt + node
85
+ * @returns {object} Formatted starred repo
86
+ */
87
+ function formatStarredRepo(edge) {
88
+ const repo = edge.node;
89
+ return {
90
+ fullName: repo.nameWithOwner,
91
+ name: repo.name,
92
+ description: repo.description || "",
93
+ url: repo.url,
94
+ language: repo.primaryLanguage?.name || null,
95
+ stars: repo.stargazerCount,
96
+ forks: repo.forkCount,
97
+ topics: (repo.repositoryTopics?.nodes || []).map((n) => n.topic.name),
98
+ license: repo.licenseInfo?.spdxId || null,
99
+ archived: repo.isArchived,
100
+ starredAt: edge.starredAt,
101
+ ownerAvatar: repo.owner?.avatarUrl || "",
102
+ ownerLogin: repo.owner?.login || "",
103
+ pushedAt: repo.pushedAt,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Fetch all starred repositories via GraphQL pagination
109
+ * @param {string} token - GitHub personal access token (REQUIRED for GraphQL)
110
+ * @param {object} [options] - Fetch options
111
+ * @param {number} [options.maxPages] - Max pages to fetch (null = all)
112
+ * @param {Function} [options.onPage] - Callback after each page: (pageNum, totalFetched, totalCount)
113
+ * @returns {Promise<{stars: Array, totalCount: number}>}
114
+ */
115
+ export async function fetchAllStarred(token, options = {}) {
116
+ if (!token) {
117
+ throw new Error("GitHub token is required for GraphQL API");
118
+ }
119
+
120
+ const { maxPages = null, onPage = null } = options;
121
+ const allStars = [];
122
+ let cursor = null;
123
+ let hasNextPage = true;
124
+ let totalCount = 0;
125
+ let pageNum = 0;
126
+
127
+ while (hasNextPage) {
128
+ if (maxPages !== null && pageNum >= maxPages) break;
129
+
130
+ const response = await fetch(GITHUB_GRAPHQL_URL, {
131
+ method: "POST",
132
+ headers: {
133
+ Authorization: `Bearer ${token}`,
134
+ "Content-Type": "application/json",
135
+ },
136
+ body: JSON.stringify({
137
+ query: STARRED_QUERY,
138
+ variables: { cursor },
139
+ }),
140
+ });
141
+
142
+ if (!response.ok) {
143
+ throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`);
144
+ }
145
+
146
+ const body = await response.json();
147
+
148
+ if (body.errors) {
149
+ throw new Error(`GraphQL errors: ${body.errors.map((e) => e.message).join(", ")}`);
150
+ }
151
+
152
+ const starred = body.data.viewer.starredRepositories;
153
+ totalCount = starred.totalCount;
154
+
155
+ for (const edge of starred.edges) {
156
+ allStars.push(formatStarredRepo(edge));
157
+ }
158
+
159
+ cursor = starred.pageInfo.endCursor;
160
+ hasNextPage = starred.pageInfo.hasNextPage;
161
+ pageNum++;
162
+
163
+ if (onPage) {
164
+ onPage(pageNum, allStars.length, totalCount);
165
+ }
166
+
167
+ // Small delay between pages to avoid secondary rate limits
168
+ if (hasNextPage) {
169
+ await new Promise((resolve) => setTimeout(resolve, 200));
170
+ }
171
+ }
172
+
173
+ return { stars: allStars, totalCount };
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
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * MongoDB cache layer for starred repositories
3
+ * Follows the webmention-io pattern: receives db object, not Indiekit instance
4
+ * @module starred-cache
5
+ */
6
+
7
+ /**
8
+ * Ensure required indexes exist on the github_stars collection
9
+ * @param {import("mongodb").Collection} collection
10
+ */
11
+ export async function ensureIndexes(collection) {
12
+ await collection.createIndex({ fullName: 1 }, { unique: true });
13
+ await collection.createIndex({ starredAt: -1 });
14
+ await collection.createIndex({ language: 1 });
15
+ await collection.createIndex({ lists: 1 });
16
+ }
17
+
18
+ /**
19
+ * Bulk upsert starred repos into the collection
20
+ * Uses fullName as unique key
21
+ * @param {import("mongodb").Collection} collection
22
+ * @param {Array} stars - Array of formatted starred repos
23
+ * @returns {Promise<{upserted: number, modified: number}>}
24
+ */
25
+ export async function syncStars(collection, stars) {
26
+ if (!stars || stars.length === 0) return { upserted: 0, modified: 0 };
27
+
28
+ const operations = stars.map((star) => ({
29
+ updateOne: {
30
+ filter: { fullName: star.fullName },
31
+ update: {
32
+ $set: {
33
+ ...star,
34
+ updatedAt: new Date().toISOString(),
35
+ },
36
+ },
37
+ upsert: true,
38
+ },
39
+ }));
40
+
41
+ // Process in batches of 500 to avoid MongoDB limits
42
+ let upserted = 0;
43
+ let modified = 0;
44
+
45
+ for (let i = 0; i < operations.length; i += 500) {
46
+ const batch = operations.slice(i, i + 500);
47
+ const result = await collection.bulkWrite(batch, { ordered: false });
48
+ upserted += result.upsertedCount;
49
+ modified += result.modifiedCount;
50
+ }
51
+
52
+ return { upserted, modified };
53
+ }
54
+
55
+ /**
56
+ * Remove starred repos that no longer exist in the full list
57
+ * Called during full sync to handle unstarred repos
58
+ * @param {import("mongodb").Collection} collection
59
+ * @param {Set<string>} currentFullNames - Set of all current starred repo fullNames
60
+ * @returns {Promise<number>} Number of removed repos
61
+ */
62
+ export async function removeUnstarred(collection, currentFullNames) {
63
+ const result = await collection.deleteMany({
64
+ fullName: { $nin: [...currentFullNames] },
65
+ });
66
+ return result.deletedCount;
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
+
111
+ /**
112
+ * Get all starred repos sorted by starredAt descending
113
+ * @param {import("mongodb").Collection} collection
114
+ * @returns {Promise<Array>}
115
+ */
116
+ export async function getAllStars(collection) {
117
+ return collection
118
+ .find({}, { projection: { _id: 0, updatedAt: 0 } })
119
+ .sort({ starredAt: -1 })
120
+ .toArray();
121
+ }
122
+
123
+ /**
124
+ * Get stars added since a given date
125
+ * @param {import("mongodb").Collection} collection
126
+ * @param {string} since - ISO 8601 date string
127
+ * @returns {Promise<Array>}
128
+ */
129
+ export async function getRecentStars(collection, since) {
130
+ return collection
131
+ .find(
132
+ { starredAt: { $gt: since } },
133
+ { projection: { _id: 0, updatedAt: 0 } },
134
+ )
135
+ .sort({ starredAt: -1 })
136
+ .toArray();
137
+ }
138
+
139
+ /**
140
+ * Get total count of starred repos in cache
141
+ * @param {import("mongodb").Collection} collection
142
+ * @returns {Promise<number>}
143
+ */
144
+ export async function getStarCount(collection) {
145
+ return collection.countDocuments();
146
+ }
147
+
148
+ /**
149
+ * Get sync state from github_sync_state collection
150
+ * @param {import("mongodb").Db} db
151
+ * @returns {Promise<object>}
152
+ */
153
+ export async function getSyncState(db) {
154
+ const stateCollection = db.collection("github_sync_state");
155
+ const state = await stateCollection.findOne({ _id: "starred_sync" });
156
+ return state || { lastIncrementalSync: null, lastFullSync: null };
157
+ }
158
+
159
+ /**
160
+ * Update sync state
161
+ * @param {import("mongodb").Db} db
162
+ * @param {object} update - Fields to update
163
+ */
164
+ export async function updateSyncState(db, update) {
165
+ const stateCollection = db.collection("github_sync_state");
166
+ await stateCollection.findOneAndUpdate(
167
+ { _id: "starred_sync" },
168
+ { $set: update },
169
+ { upsert: true },
170
+ );
171
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Background sync scheduler for starred repositories
3
+ * Follows the webmention-io startSync/stopSync pattern
4
+ * @module starred-sync
5
+ */
6
+
7
+ import { fetchAllStarred, fetchAllLists } from "./github-graphql.js";
8
+ import {
9
+ ensureIndexes,
10
+ syncStars,
11
+ removeUnstarred,
12
+ annotateWithLists,
13
+ getStarCount,
14
+ getSyncState,
15
+ updateSyncState,
16
+ } from "./starred-cache.js";
17
+
18
+ const SIX_HOURS = 6 * 60 * 60 * 1000;
19
+ const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
20
+
21
+ let incrementalTimer = null;
22
+ let fullSyncTimer = null;
23
+
24
+ let syncStatus = {
25
+ syncing: false,
26
+ lastSync: null,
27
+ lastError: null,
28
+ totalStars: 0,
29
+ };
30
+
31
+ /**
32
+ * Get current sync status (safe copy for templates/API)
33
+ * @returns {object}
34
+ */
35
+ export function getStarredSyncStatus() {
36
+ return { ...syncStatus };
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
+
70
+ /**
71
+ * Start the background sync scheduler
72
+ * @param {object} Indiekit - Indiekit instance
73
+ * @param {object} options - Plugin options (needs token, username)
74
+ */
75
+ export function startStarredSync(Indiekit, options) {
76
+ if (!options.token) {
77
+ console.warn("[GitHub Stars] No token configured, starred sync disabled");
78
+ return;
79
+ }
80
+
81
+ const db = Indiekit.database;
82
+ if (!db) {
83
+ console.warn("[GitHub Stars] No database available, starred sync disabled");
84
+ return;
85
+ }
86
+
87
+ console.log("[GitHub Stars] Starting background sync");
88
+
89
+ // Initial sync after 15s delay (let other plugins init first)
90
+ setTimeout(async () => {
91
+ try {
92
+ const collection = db.collection("github_stars");
93
+ await ensureIndexes(collection);
94
+
95
+ const count = await getStarCount(collection);
96
+ if (count === 0) {
97
+ console.log("[GitHub Stars] Empty cache, running full sync...");
98
+ await runFullSync(db, options);
99
+ } else {
100
+ console.log(`[GitHub Stars] Cache has ${count} stars, running incremental sync...`);
101
+ await runIncrementalSync(db, options);
102
+ }
103
+
104
+ // Sync lists after initial star sync
105
+ await syncLists(db, options);
106
+ } catch (error) {
107
+ console.error("[GitHub Stars] Initial sync error:", error.message);
108
+ }
109
+ }, 15_000);
110
+
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) {
117
+ console.error("[GitHub Stars] Incremental sync error:", err.message);
118
+ }
119
+ }, SIX_HOURS);
120
+
121
+ // Full sync every 7 days
122
+ fullSyncTimer = setInterval(() => {
123
+ runFullSync(db, options).catch((err) => {
124
+ console.error("[GitHub Stars] Full sync error:", err.message);
125
+ });
126
+ }, SEVEN_DAYS);
127
+ }
128
+
129
+ /**
130
+ * Stop all sync timers
131
+ */
132
+ export function stopStarredSync() {
133
+ if (incrementalTimer) {
134
+ clearInterval(incrementalTimer);
135
+ incrementalTimer = null;
136
+ }
137
+ if (fullSyncTimer) {
138
+ clearInterval(fullSyncTimer);
139
+ fullSyncTimer = null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Run incremental sync (fetch first 2 pages = 200 most recent stars)
145
+ * @param {import("mongodb").Db} db
146
+ * @param {object} options - Plugin options
147
+ * @returns {Promise<object>} Sync result
148
+ */
149
+ export async function runIncrementalSync(db, options) {
150
+ if (syncStatus.syncing) {
151
+ return { error: "Sync already in progress" };
152
+ }
153
+
154
+ syncStatus.syncing = true;
155
+ syncStatus.lastError = null;
156
+
157
+ try {
158
+ const collection = db.collection("github_stars");
159
+ await ensureIndexes(collection);
160
+
161
+ const { stars } = await fetchAllStarred(options.token, { maxPages: 2 });
162
+
163
+ const result = await syncStars(collection, stars);
164
+ const totalStars = await getStarCount(collection);
165
+
166
+ syncStatus.lastSync = new Date().toISOString();
167
+ syncStatus.totalStars = totalStars;
168
+ syncStatus.syncing = false;
169
+
170
+ await updateSyncState(db, {
171
+ lastIncrementalSync: syncStatus.lastSync,
172
+ totalStars,
173
+ });
174
+
175
+ console.log(
176
+ `[GitHub Stars] Incremental sync: ${result.upserted} new, ${result.modified} updated, ${totalStars} total`,
177
+ );
178
+
179
+ return { ...result, totalStars };
180
+ } catch (error) {
181
+ syncStatus.lastError = error.message;
182
+ syncStatus.syncing = false;
183
+ console.error("[GitHub Stars] Incremental sync failed:", error.message);
184
+ return { error: error.message };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Run full sync (fetch ALL starred repos, remove unstarred)
190
+ * @param {import("mongodb").Db} db
191
+ * @param {object} options - Plugin options
192
+ * @returns {Promise<object>} Sync result
193
+ */
194
+ export async function runFullSync(db, options) {
195
+ if (syncStatus.syncing) {
196
+ return { error: "Sync already in progress" };
197
+ }
198
+
199
+ syncStatus.syncing = true;
200
+ syncStatus.lastError = null;
201
+
202
+ try {
203
+ const collection = db.collection("github_stars");
204
+ await ensureIndexes(collection);
205
+
206
+ const { stars, totalCount } = await fetchAllStarred(options.token, {
207
+ onPage: (page, fetched, total) => {
208
+ console.log(`[GitHub Stars] Full sync: page ${page}, ${fetched}/${total} fetched`);
209
+ },
210
+ });
211
+
212
+ const result = await syncStars(collection, stars);
213
+
214
+ // Remove repos that were unstarred
215
+ const currentNames = new Set(stars.map((s) => s.fullName));
216
+ const removed = await removeUnstarred(collection, currentNames);
217
+
218
+ const totalStars = await getStarCount(collection);
219
+
220
+ syncStatus.lastSync = new Date().toISOString();
221
+ syncStatus.totalStars = totalStars;
222
+ syncStatus.syncing = false;
223
+
224
+ await updateSyncState(db, {
225
+ lastFullSync: syncStatus.lastSync,
226
+ lastIncrementalSync: syncStatus.lastSync,
227
+ totalStars,
228
+ });
229
+
230
+ // Sync lists during full sync
231
+ await syncLists(db, options);
232
+
233
+ console.log(
234
+ `[GitHub Stars] Full sync complete: ${result.upserted} new, ${result.modified} updated, ${removed} removed, ${totalStars} total (GitHub reports ${totalCount})`,
235
+ );
236
+
237
+ return { ...result, removed, totalStars, totalCount };
238
+ } catch (error) {
239
+ syncStatus.lastError = error.message;
240
+ syncStatus.syncing = false;
241
+ console.error("[GitHub Stars] Full sync failed:", error.message);
242
+ return { error: error.message };
243
+ }
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-github",
3
- "version": "1.0.7",
3
+ "version": "1.2.0",
4
4
  "description": "GitHub activity endpoint for Indiekit. Display commits, stars, contributions, and featured repositories.",
5
5
  "keywords": [
6
6
  "indiekit",