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