@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 +21 -1
- package/lib/controllers/starred.js +119 -0
- package/lib/github-client.js +11 -1
- package/lib/github-graphql.js +288 -0
- package/lib/starred-cache.js +171 -0
- package/lib/starred-sync.js +244 -0
- package/package.json +1 -1
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
|
-
//
|
|
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
|
+
};
|
package/lib/github-client.js
CHANGED
|
@@ -40,7 +40,17 @@ export class GitHubClient {
|
|
|
40
40
|
const response = await fetch(url, { headers });
|
|
41
41
|
|
|
42
42
|
if (!response.ok) {
|
|
43
|
-
|
|
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