KrishnaCosmic commited on
Commit
cd4ab7b
·
1 Parent(s): 1c3556c

optimization 1

Browse files
src/app/api/sync/run/route.ts CHANGED
@@ -2,11 +2,12 @@
2
  * Sync Trigger API Route
3
  *
4
  * POST /api/sync/run - Manually trigger a full GitHub sync
 
5
  */
6
 
7
  import { NextRequest, NextResponse } from "next/server";
8
  import { getCurrentUser } from "@/lib/auth";
9
- import { runFullSync, SYNC_INTERVAL_MS } from "@/lib/sync/github-sync";
10
 
11
  export async function POST(request: NextRequest) {
12
  try {
@@ -15,21 +16,26 @@ export async function POST(request: NextRequest) {
15
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
16
  }
17
 
18
- // Only maintainers can trigger sync
19
- if (user.role !== "MAINTAINER" && user.role !== "maintainer") {
20
- return NextResponse.json({ error: "Maintainer access required" }, { status: 403 });
21
- }
22
-
23
  if (!user.githubAccessToken) {
24
  return NextResponse.json({ error: "GitHub access token not found" }, { status: 400 });
25
  }
26
 
27
- // Run the sync
28
- const stats = await runFullSync(user.id, user.githubAccessToken);
 
 
 
 
 
 
 
 
 
29
 
30
  return NextResponse.json({
31
  success: true,
32
  message: "Sync completed",
 
33
  stats,
34
  nextSyncIntervalMs: SYNC_INTERVAL_MS,
35
  });
@@ -45,5 +51,10 @@ export async function GET(request: NextRequest) {
45
  syncIntervalMs: SYNC_INTERVAL_MS,
46
  syncIntervalMinutes: SYNC_INTERVAL_MS / 60000,
47
  description: "GitHub sync runs every 5 minutes. POST to trigger manually.",
 
 
 
 
 
48
  });
49
  }
 
2
  * Sync Trigger API Route
3
  *
4
  * POST /api/sync/run - Manually trigger a full GitHub sync
5
+ * Supports role-based sync with ETag caching for efficient API usage
6
  */
7
 
8
  import { NextRequest, NextResponse } from "next/server";
9
  import { getCurrentUser } from "@/lib/auth";
10
+ import { runFullSync, runMaintainerSync, runContributorSync, SYNC_INTERVAL_MS } from "@/lib/sync/github-sync";
11
 
12
  export async function POST(request: NextRequest) {
13
  try {
 
16
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
17
  }
18
 
 
 
 
 
 
19
  if (!user.githubAccessToken) {
20
  return NextResponse.json({ error: "GitHub access token not found" }, { status: 400 });
21
  }
22
 
23
+ // Determine sync type based on user role
24
+ const userRole = user.role?.toUpperCase();
25
+ let stats;
26
+
27
+ if (userRole === "MAINTAINER") {
28
+ // Maintainers sync all open issues/PRs
29
+ stats = await runMaintainerSync(user.id, user.githubAccessToken);
30
+ } else {
31
+ // Contributors sync only their authored PRs
32
+ stats = await runContributorSync(user.id, user.username, user.githubAccessToken);
33
+ }
34
 
35
  return NextResponse.json({
36
  success: true,
37
  message: "Sync completed",
38
+ role: userRole,
39
  stats,
40
  nextSyncIntervalMs: SYNC_INTERVAL_MS,
41
  });
 
51
  syncIntervalMs: SYNC_INTERVAL_MS,
52
  syncIntervalMinutes: SYNC_INTERVAL_MS / 60000,
53
  description: "GitHub sync runs every 5 minutes. POST to trigger manually.",
54
+ features: [
55
+ "ETag caching - skips sync if no changes (304 Not Modified)",
56
+ "State reconciliation - marks issues as closed if not in open list",
57
+ "Role-based filtering - Contributors see only their authored PRs",
58
+ ],
59
  });
60
  }
src/db/schema.ts CHANGED
@@ -49,6 +49,8 @@ export const repositories = sqliteTable("repositories", {
49
  name: text("name").notNull(),
50
  owner: text("owner").notNull(),
51
  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
 
 
52
  createdAt: text("created_at").notNull(),
53
  });
54
 
 
49
  name: text("name").notNull(),
50
  owner: text("owner").notNull(),
51
  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
52
+ etag: text("etag"), // GitHub ETag for conditional requests
53
+ lastSyncedAt: text("last_synced_at"), // When the repo was last synced
54
  createdAt: text("created_at").notNull(),
55
  });
56
 
src/lib/routeConfig.ts CHANGED
@@ -16,7 +16,6 @@ export const publicRoutes: string[] = [
16
  export const maintainerOnlyRoutes: string[] = [
17
  '/api/maintainer',
18
  '/api/repositories',
19
- '/api/sync',
20
  ];
21
 
22
  // Routes that require CONTRIBUTOR role
@@ -37,6 +36,7 @@ export const authenticatedRoutes: string[] = [
37
  '/api/mentor',
38
  '/api/badges',
39
  '/api/user',
 
40
  ];
41
 
42
  /**
 
16
  export const maintainerOnlyRoutes: string[] = [
17
  '/api/maintainer',
18
  '/api/repositories',
 
19
  ];
20
 
21
  // Routes that require CONTRIBUTOR role
 
36
  '/api/mentor',
37
  '/api/badges',
38
  '/api/user',
39
+ '/api/sync', // Both maintainers and contributors can sync
40
  ];
41
 
42
  /**
src/lib/sync/github-sync.ts CHANGED
@@ -2,6 +2,7 @@
2
  * GitHub Sync Service
3
  *
4
  * Handles synchronization of issues and PRs from GitHub to the database.
 
5
  * Uses Promise.allSettled for resilient parallel fetching.
6
  */
7
 
@@ -27,16 +28,23 @@ interface SyncResult {
27
  created: number;
28
  updated: number;
29
  deleted: number;
 
30
  error?: string;
31
  }
32
 
33
  interface SyncStats {
34
  reposProcessed: number;
 
35
  issuesUpdated: number;
36
  issuesDeleted: number;
37
  errors: string[];
38
  }
39
 
 
 
 
 
 
40
  // =============================================================================
41
  // Issue Deletion
42
  // =============================================================================
@@ -67,43 +75,103 @@ export async function getIssuesByRepoId(repoId: string) {
67
  }
68
 
69
  // =============================================================================
70
- // Single Repository Sync
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  // =============================================================================
72
 
73
  async function syncRepository(
74
  octokit: Octokit,
75
  repoId: string,
76
  owner: string,
77
- repo: string
 
78
  ): Promise<SyncResult> {
79
  const repoName = `${owner}/${repo}`;
80
 
81
  try {
82
- // Fetch all open issues from GitHub (paginated)
83
- const openItems = await octokit.paginate(octokit.issues.listForRepo, {
84
- owner,
85
- repo,
86
- state: "open",
87
- per_page: 100,
88
- });
89
-
90
- // Fetch recent closed issues
91
- const closedResponse = await octokit.issues.listForRepo({
92
- owner,
93
- repo,
94
- state: "closed",
95
- per_page: 50,
96
- sort: "updated",
97
- direction: "desc",
98
- });
99
- const closedItems = closedResponse.data;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
  // Get current issues in DB for this repo
102
  const dbIssues = await getIssuesByRepoId(repoId);
103
  const dbIssuesByGithubId = new Map(dbIssues.map(i => [i.githubIssueId, i]));
104
 
105
- // Track GitHub IDs we've seen in this sync
106
- const seenGithubIds = new Set<number>();
107
  // Track potential mentors to update roles
108
  const potentialMentors = new Set<string>();
109
 
@@ -121,10 +189,27 @@ async function syncRepository(
121
  }
122
  };
123
 
 
 
 
 
 
 
 
 
 
 
 
124
  // Process open items
125
- for (const ghItem of openItems as GitHubIssue[]) {
126
- seenGithubIds.add(ghItem.id);
127
  checkMentorStatus(ghItem);
 
 
 
 
 
 
128
  const isPR = !!ghItem.pull_request;
129
  const existingIssue = dbIssuesByGithubId.get(ghItem.id);
130
 
@@ -195,43 +280,16 @@ async function syncRepository(
195
  }
196
  }
197
 
198
- // Process closed items - explicitly delete them
199
- for (const ghItem of closedItems as GitHubIssue[]) {
200
- seenGithubIds.add(ghItem.id);
201
- checkMentorStatus(ghItem); // Also check closed items for mentor activity
202
- const existingIssue = dbIssuesByGithubId.get(ghItem.id);
203
-
204
- if (existingIssue) {
205
- const isPR = !!ghItem.pull_request;
206
- const isMerged = ghItem.pull_request?.merged_at !== null;
207
-
208
- await deleteIssue(existingIssue.id);
209
- deleted++;
210
-
211
- if (isAblyConfigured()) {
212
- await publishIssueDeleted({
213
- id: existingIssue.id,
214
- githubIssueId: ghItem.id,
215
- number: ghItem.number,
216
- title: ghItem.title,
217
- repoName,
218
- owner,
219
- repo,
220
- isPR,
221
- state: isMerged ? "merged" : "closed",
222
- });
223
- }
224
- }
225
- }
226
-
227
- // Cleanup: Delete any DB issue that is marked 'open' but was NOT found in the full GitHub open list
228
- // This ensures strict synchronization - if it's not in GitHub's open list, it shouldn't be open in our DB
229
  for (const dbIssue of dbIssues) {
230
- // Only strictly clean up things we think are open
231
- if (dbIssue.state === 'open' && !seenGithubIds.has(dbIssue.githubIssueId)) {
232
- // We double check: if it's open in DB, but missing from Open pagination -> it must be closed/deleted
233
- // verification is implied by the comprehensive open fetch
234
- await deleteIssue(dbIssue.id);
235
  deleted++;
236
 
237
  if (isAblyConfigured()) {
@@ -239,14 +297,13 @@ async function syncRepository(
239
  id: dbIssue.id,
240
  githubIssueId: dbIssue.githubIssueId,
241
  number: dbIssue.number,
242
- title: dbIssue.title,
243
  repoName,
244
  owner,
245
  repo,
246
- isPR: dbIssue.isPR,
247
- state: "closed", // Assumed closed since missing from open
248
  });
249
  }
 
 
250
  }
251
  }
252
 
@@ -267,11 +324,11 @@ async function syncRepository(
267
  }
268
  }
269
 
270
- return { repoId, repoName, success: true, created, updated, deleted };
271
  } catch (error) {
272
  const errorMessage = error instanceof Error ? error.message : String(error);
273
  console.error(`Sync error for ${repoName}:`, errorMessage);
274
- return { repoId, repoName, success: false, created: 0, updated: 0, deleted: 0, error: errorMessage };
275
  }
276
  }
277
 
@@ -279,7 +336,11 @@ async function syncRepository(
279
  // Full Sync (All Repositories)
280
  // =============================================================================
281
 
282
- export async function runFullSync(userId: string, accessToken: string): Promise<SyncStats> {
 
 
 
 
283
  const octokit = new Octokit({ auth: accessToken });
284
 
285
  // Get all repositories for this user
@@ -288,12 +349,12 @@ export async function runFullSync(userId: string, accessToken: string): Promise<
288
  .where(eq(repositories.userId, userId));
289
 
290
  if (userRepos.length === 0) {
291
- return { reposProcessed: 0, issuesUpdated: 0, issuesDeleted: 0, errors: [] };
292
  }
293
 
294
  // Sync all repos in parallel using Promise.allSettled
295
  const syncPromises = userRepos.map(repo =>
296
- syncRepository(octokit, repo.id, repo.owner, repo.name)
297
  );
298
 
299
  const results = await Promise.allSettled(syncPromises);
@@ -301,6 +362,7 @@ export async function runFullSync(userId: string, accessToken: string): Promise<
301
  // Aggregate results
302
  const stats: SyncStats = {
303
  reposProcessed: 0,
 
304
  issuesUpdated: 0,
305
  issuesDeleted: 0,
306
  errors: [],
@@ -310,8 +372,14 @@ export async function runFullSync(userId: string, accessToken: string): Promise<
310
  if (result.status === "fulfilled") {
311
  const syncResult = result.value;
312
  stats.reposProcessed++;
313
- stats.issuesUpdated += syncResult.created + syncResult.updated;
314
- stats.issuesDeleted += syncResult.deleted;
 
 
 
 
 
 
315
  if (!syncResult.success && syncResult.error) {
316
  stats.errors.push(`${syncResult.repoName}: ${syncResult.error}`);
317
  }
@@ -333,6 +401,8 @@ export async function runFullSync(userId: string, accessToken: string): Promise<
333
  }
334
  }
335
 
 
 
336
  return stats;
337
  }
338
 
@@ -344,8 +414,37 @@ export async function syncSingleRepository(
344
  accessToken: string,
345
  repoId: string,
346
  owner: string,
347
- repo: string
 
348
  ): Promise<SyncResult> {
349
  const octokit = new Octokit({ auth: accessToken });
350
- return syncRepository(octokit, repoId, owner, repo);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  }
 
2
  * GitHub Sync Service
3
  *
4
  * Handles synchronization of issues and PRs from GitHub to the database.
5
+ * Uses ETag caching for efficient API usage (5,000 req/hr limit).
6
  * Uses Promise.allSettled for resilient parallel fetching.
7
  */
8
 
 
28
  created: number;
29
  updated: number;
30
  deleted: number;
31
+ skipped: boolean; // True if 304 Not Modified
32
  error?: string;
33
  }
34
 
35
  interface SyncStats {
36
  reposProcessed: number;
37
+ reposSkipped: number; // Repos that returned 304 Not Modified
38
  issuesUpdated: number;
39
  issuesDeleted: number;
40
  errors: string[];
41
  }
42
 
43
+ interface SyncOptions {
44
+ role?: 'MAINTAINER' | 'CONTRIBUTOR';
45
+ username?: string; // For contributor-specific filtering
46
+ }
47
+
48
  // =============================================================================
49
  // Issue Deletion
50
  // =============================================================================
 
75
  }
76
 
77
  // =============================================================================
78
+ // Update Repository ETag
79
+ // =============================================================================
80
+
81
+ async function updateRepositoryEtag(repoId: string, etag: string | null): Promise<void> {
82
+ await db.update(repositories)
83
+ .set({
84
+ etag: etag,
85
+ lastSyncedAt: new Date().toISOString()
86
+ })
87
+ .where(eq(repositories.id, repoId));
88
+ }
89
+
90
+ async function getRepositoryEtag(repoId: string): Promise<string | null> {
91
+ const repo = await db.select({ etag: repositories.etag })
92
+ .from(repositories)
93
+ .where(eq(repositories.id, repoId))
94
+ .limit(1);
95
+ return repo[0]?.etag || null;
96
+ }
97
+
98
+ // =============================================================================
99
+ // Single Repository Sync (with ETag Caching)
100
  // =============================================================================
101
 
102
  async function syncRepository(
103
  octokit: Octokit,
104
  repoId: string,
105
  owner: string,
106
+ repo: string,
107
+ options: SyncOptions = {}
108
  ): Promise<SyncResult> {
109
  const repoName = `${owner}/${repo}`;
110
 
111
  try {
112
+ // Get stored ETag for conditional request
113
+ const storedEtag = await getRepositoryEtag(repoId);
114
+
115
+ // Build headers for conditional request
116
+ const headers: Record<string, string> = {};
117
+ if (storedEtag) {
118
+ headers['If-None-Match'] = storedEtag;
119
+ }
120
+
121
+ // Fetch open issues with conditional request
122
+ let openItems: GitHubIssue[] = [];
123
+ let newEtag: string | null = null;
124
+
125
+ try {
126
+ const response = await octokit.issues.listForRepo({
127
+ owner,
128
+ repo,
129
+ state: "open",
130
+ per_page: 100,
131
+ headers,
132
+ });
133
+
134
+ // Get ETag from response headers
135
+ newEtag = response.headers.etag || null;
136
+
137
+ // If we got data, paginate for the rest
138
+ openItems = response.data as GitHubIssue[];
139
+
140
+ // Paginate if there are more items
141
+ if (response.data.length === 100) {
142
+ const remainingItems = await octokit.paginate(octokit.issues.listForRepo, {
143
+ owner,
144
+ repo,
145
+ state: "open",
146
+ per_page: 100,
147
+ page: 2, // Start from page 2
148
+ });
149
+ openItems = [...openItems, ...(remainingItems as GitHubIssue[])];
150
+ }
151
+ } catch (error: any) {
152
+ // Check for 304 Not Modified
153
+ if (error.status === 304) {
154
+ console.log(`[Sync] ${repoName}: No changes (304 Not Modified)`);
155
+ // Update last synced timestamp even for 304
156
+ await db.update(repositories)
157
+ .set({ lastSyncedAt: new Date().toISOString() })
158
+ .where(eq(repositories.id, repoId));
159
+ return { repoId, repoName, success: true, created: 0, updated: 0, deleted: 0, skipped: true };
160
+ }
161
+ throw error;
162
+ }
163
+
164
+ // Update stored ETag if we got a new one
165
+ if (newEtag) {
166
+ await updateRepositoryEtag(repoId, newEtag);
167
+ }
168
 
169
  // Get current issues in DB for this repo
170
  const dbIssues = await getIssuesByRepoId(repoId);
171
  const dbIssuesByGithubId = new Map(dbIssues.map(i => [i.githubIssueId, i]));
172
 
173
+ // Track GitHub IDs we've seen from the open list
174
+ const openGithubIds = new Set<number>();
175
  // Track potential mentors to update roles
176
  const potentialMentors = new Set<string>();
177
 
 
189
  }
190
  };
191
 
192
+ // For Contributors: filter to only their PRs
193
+ const shouldIncludeItem = (item: GitHubIssue): boolean => {
194
+ if (options.role === 'CONTRIBUTOR' && options.username) {
195
+ // Contributors only see their own authored PRs
196
+ const isPR = !!item.pull_request;
197
+ return isPR && item.user.login === options.username;
198
+ }
199
+ // Maintainers see everything
200
+ return true;
201
+ };
202
+
203
  // Process open items
204
+ for (const ghItem of openItems) {
205
+ openGithubIds.add(ghItem.id);
206
  checkMentorStatus(ghItem);
207
+
208
+ // Skip items that don't match role filter
209
+ if (!shouldIncludeItem(ghItem)) {
210
+ continue;
211
+ }
212
+
213
  const isPR = !!ghItem.pull_request;
214
  const existingIssue = dbIssuesByGithubId.get(ghItem.id);
215
 
 
280
  }
281
  }
282
 
283
+ // =========================================================================
284
+ // STATE RECONCILIATION: Delete issues/PRs that are closed on GitHub
285
+ // If an issue is in our DB but NOT in GitHub's open list, delete it
286
+ // =========================================================================
287
+ let deleted = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  for (const dbIssue of dbIssues) {
289
+ // If issue is in DB but NOT in GitHub's open list, it's closed - delete it
290
+ if (!openGithubIds.has(dbIssue.githubIssueId)) {
291
+ await db.delete(issues)
292
+ .where(eq(issues.id, dbIssue.id));
 
293
  deleted++;
294
 
295
  if (isAblyConfigured()) {
 
297
  id: dbIssue.id,
298
  githubIssueId: dbIssue.githubIssueId,
299
  number: dbIssue.number,
 
300
  repoName,
301
  owner,
302
  repo,
 
 
303
  });
304
  }
305
+
306
+ console.log(`[Sync] ${repoName}: Deleted #${dbIssue.number} (closed on GitHub)`);
307
  }
308
  }
309
 
 
324
  }
325
  }
326
 
327
+ return { repoId, repoName, success: true, created, updated, deleted, skipped: false };
328
  } catch (error) {
329
  const errorMessage = error instanceof Error ? error.message : String(error);
330
  console.error(`Sync error for ${repoName}:`, errorMessage);
331
+ return { repoId, repoName, success: false, created: 0, updated: 0, deleted: 0, skipped: false, error: errorMessage };
332
  }
333
  }
334
 
 
336
  // Full Sync (All Repositories)
337
  // =============================================================================
338
 
339
+ export async function runFullSync(
340
+ userId: string,
341
+ accessToken: string,
342
+ options: SyncOptions = {}
343
+ ): Promise<SyncStats> {
344
  const octokit = new Octokit({ auth: accessToken });
345
 
346
  // Get all repositories for this user
 
349
  .where(eq(repositories.userId, userId));
350
 
351
  if (userRepos.length === 0) {
352
+ return { reposProcessed: 0, reposSkipped: 0, issuesUpdated: 0, issuesDeleted: 0, errors: [] };
353
  }
354
 
355
  // Sync all repos in parallel using Promise.allSettled
356
  const syncPromises = userRepos.map(repo =>
357
+ syncRepository(octokit, repo.id, repo.owner, repo.name, options)
358
  );
359
 
360
  const results = await Promise.allSettled(syncPromises);
 
362
  // Aggregate results
363
  const stats: SyncStats = {
364
  reposProcessed: 0,
365
+ reposSkipped: 0,
366
  issuesUpdated: 0,
367
  issuesDeleted: 0,
368
  errors: [],
 
372
  if (result.status === "fulfilled") {
373
  const syncResult = result.value;
374
  stats.reposProcessed++;
375
+
376
+ if (syncResult.skipped) {
377
+ stats.reposSkipped++;
378
+ } else {
379
+ stats.issuesUpdated += syncResult.created + syncResult.updated;
380
+ stats.issuesDeleted += syncResult.deleted;
381
+ }
382
+
383
  if (!syncResult.success && syncResult.error) {
384
  stats.errors.push(`${syncResult.repoName}: ${syncResult.error}`);
385
  }
 
401
  }
402
  }
403
 
404
+ console.log(`[Sync] Complete: ${stats.reposProcessed} repos, ${stats.reposSkipped} skipped (304), ${stats.issuesUpdated} updated, ${stats.issuesDeleted} deleted`);
405
+
406
  return stats;
407
  }
408
 
 
414
  accessToken: string,
415
  repoId: string,
416
  owner: string,
417
+ repo: string,
418
+ options: SyncOptions = {}
419
  ): Promise<SyncResult> {
420
  const octokit = new Octokit({ auth: accessToken });
421
+ return syncRepository(octokit, repoId, owner, repo, options);
422
+ }
423
+
424
+ // =============================================================================
425
+ // Contributor Sync (Only their authored PRs)
426
+ // =============================================================================
427
+
428
+ export async function runContributorSync(
429
+ userId: string,
430
+ username: string,
431
+ accessToken: string
432
+ ): Promise<SyncStats> {
433
+ return runFullSync(userId, accessToken, {
434
+ role: 'CONTRIBUTOR',
435
+ username,
436
+ });
437
+ }
438
+
439
+ // =============================================================================
440
+ // Maintainer Sync (All open issues/PRs)
441
+ // =============================================================================
442
+
443
+ export async function runMaintainerSync(
444
+ userId: string,
445
+ accessToken: string
446
+ ): Promise<SyncStats> {
447
+ return runFullSync(userId, accessToken, {
448
+ role: 'MAINTAINER',
449
+ });
450
  }