KrishnaCosmic commited on
Commit
845872f
·
1 Parent(s): 9458a3a

apply new changes

Browse files
src/app/api/issues/[id]/details/route.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * GET /api/issues/[id]/details
3
+ *
4
+ * Fetch full issue details including the complete body text.
5
+ * This endpoint is called on-demand when a user clicks into an issue detail view,
6
+ * keeping the paginated list view lightweight (only bodySummary sent).
7
+ *
8
+ * REFACTOR: Separates hot-path pagination queries from cold-path detail fetches.
9
+ */
10
+
11
+ import { NextRequest, NextResponse } from "next/server";
12
+ import { getIssueById } from "@/lib/db/queries/issues";
13
+ import { db } from "@/db";
14
+ import { issues } from "@/db/schema";
15
+ import { eq } from "drizzle-orm";
16
+
17
+ export async function GET(
18
+ request: NextRequest,
19
+ { params }: { params: { id: string } }
20
+ ) {
21
+ try {
22
+ const issueId = params.id;
23
+
24
+ if (!issueId) {
25
+ return NextResponse.json(
26
+ { error: "Issue ID is required" },
27
+ { status: 400 }
28
+ );
29
+ }
30
+
31
+ // Fetch from DB
32
+ const issue = await db.select()
33
+ .from(issues)
34
+ .where(eq(issues.id, issueId))
35
+ .limit(1);
36
+
37
+ if (!issue || issue.length === 0) {
38
+ return NextResponse.json(
39
+ { error: "Issue not found" },
40
+ { status: 404 }
41
+ );
42
+ }
43
+
44
+ const issueData = issue[0];
45
+
46
+ // If body is empty/null, try to fetch from GitHub API as fallback
47
+ if (!issueData.body) {
48
+ try {
49
+ const githubUrl = issueData.htmlUrl;
50
+ if (githubUrl) {
51
+ // GitHub's raw API: use the graphql endpoint or REST /repos/{owner}/{repo}/issues/{number}
52
+ const https = await import("https");
53
+ const response = await new Promise<{statusCode: number; body: string}>((resolve, reject) => {
54
+ https.get(
55
+ `${githubUrl}`,
56
+ { headers: { "User-Agent": "OpenTriage" } },
57
+ (res) => {
58
+ let body = "";
59
+ res.on("data", chunk => body += chunk);
60
+ res.on("end", () => resolve({ statusCode: res.statusCode || 200, body }));
61
+ res.on("error", reject);
62
+ }
63
+ ).on("error", reject);
64
+ });
65
+
66
+ if (response.statusCode === 200) {
67
+ // This would be HTML; for a real implementation, parse or fetch via REST API
68
+ issueData.body = `[Fetched from GitHub - full content available at ${githubUrl}]`;
69
+ }
70
+ }
71
+ } catch (error) {
72
+ console.error("Failed to fetch from GitHub:", error);
73
+ // Fallback: use summary if available
74
+ issueData.body = issueData.bodySummary || "[No body content available]";
75
+ }
76
+ }
77
+
78
+ return NextResponse.json({
79
+ id: issueData.id,
80
+ number: issueData.number,
81
+ title: issueData.title,
82
+ body: issueData.body, // Full content
83
+ bodySummary: issueData.bodySummary, // Summary for reference
84
+ authorName: issueData.authorName,
85
+ state: issueData.state,
86
+ isPR: issueData.isPR,
87
+ htmlUrl: issueData.htmlUrl,
88
+ repoName: issueData.repoName,
89
+ createdAt: issueData.createdAt,
90
+ updatedAt: issueData.updatedAt,
91
+ });
92
+
93
+ } catch (error) {
94
+ console.error("Error fetching issue details:", error);
95
+ return NextResponse.json(
96
+ { error: "Failed to fetch issue details" },
97
+ { status: 500 }
98
+ );
99
+ }
100
+ }
src/app/api/webhooks/github/route.ts ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * POST /api/webhooks/github
3
+ *
4
+ * GitHub Webhook Handler
5
+ *
6
+ * This endpoint receives push, pull_request, and issue events from GitHub.
7
+ * It will eventually replace the 5-minute polling loop by triggering syncs on-demand.
8
+ *
9
+ * Webhook Setup:
10
+ * 1. In GitHub repo settings > Webhooks > Add webhook
11
+ * 2. Payload URL: https://yourdomain.com/api/webhooks/github
12
+ * 3. Content type: application/json
13
+ * 4. Events: push, pull_request, issues
14
+ * 5. Secret: Set a secret in GitHub, store in env var GITHUB_WEBHOOK_SECRET
15
+ * 6. Active: ✓
16
+ *
17
+ * ROADMAP:
18
+ * - Parse webhook payload
19
+ * - Verify GitHub signature
20
+ * - Queue a sync task for the affected repo
21
+ * - Update issues/PRs in real-time without waiting for 5-min polling
22
+ */
23
+
24
+ import { NextRequest, NextResponse } from "next/server";
25
+ import crypto from "crypto";
26
+ import { db } from "@/db";
27
+ import { repositories } from "@/db/schema";
28
+ import { eq, and, sql, like } from "drizzle-orm";
29
+ import { syncSingleRepository } from "@/lib/sync/github-sync";
30
+ import { users } from "@/db/schema";
31
+
32
+ /**
33
+ * Verify the GitHub webhook signature.
34
+ * GitHub sends X-Hub-Signature-256 header with HMAC-SHA256 of the request body.
35
+ */
36
+ function verifyGitHubSignature(payload: string, signature: string): boolean {
37
+ const secret = process.env.GITHUB_WEBHOOK_SECRET;
38
+ if (!secret) {
39
+ console.warn("[Webhook] GITHUB_WEBHOOK_SECRET not set. Skipping signature verification.");
40
+ return true; // In dev, allow unsigned webhooks if secret not configured
41
+ }
42
+
43
+ const hash = crypto
44
+ .createHmac("sha256", secret)
45
+ .update(payload)
46
+ .digest("hex");
47
+ const expectedSignature = `sha256=${hash}`;
48
+
49
+ return crypto.timingSafeEqual(
50
+ Buffer.from(signature),
51
+ Buffer.from(expectedSignature)
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Extract repo info from GitHub webhook payload.
57
+ */
58
+ interface WebhookPayload {
59
+ action?: string;
60
+ repository?: {
61
+ id: number;
62
+ name: string;
63
+ full_name: string;
64
+ owner: {
65
+ login: string;
66
+ type: string;
67
+ };
68
+ html_url: string;
69
+ };
70
+ pull_request?: {
71
+ number: number;
72
+ title: string;
73
+ state: string;
74
+ };
75
+ issue?: {
76
+ number: number;
77
+ title: string;
78
+ state: string;
79
+ };
80
+ pusher?: {
81
+ name: string;
82
+ };
83
+ ref?: string; // for push events
84
+ }
85
+
86
+ export async function POST(request: NextRequest) {
87
+ try {
88
+ // Read the raw body for signature verification
89
+ const rawBody = await request.text();
90
+ const signature = request.headers.get("x-hub-signature-256") || "";
91
+
92
+ // ✅ Verify GitHub signature
93
+ if (!verifyGitHubSignature(rawBody, signature)) {
94
+ console.warn("[Webhook] Invalid GitHub signature. Rejecting webhook.");
95
+ return NextResponse.json(
96
+ { error: "Invalid signature" },
97
+ { status: 401 }
98
+ );
99
+ }
100
+
101
+ const payload: WebhookPayload = JSON.parse(rawBody);
102
+ const repo = payload.repository;
103
+
104
+ if (!repo) {
105
+ return NextResponse.json(
106
+ { error: "No repository in payload" },
107
+ { status: 400 }
108
+ );
109
+ }
110
+
111
+ const eventType = request.headers.get("x-github-event") || "unknown";
112
+ const repoName = repo.full_name;
113
+ const [owner, repoShort] = repoName.split("/");
114
+
115
+ console.log(`[Webhook] Received ${eventType} event for ${repoName}`);
116
+
117
+ // ============================================================================
118
+ // HANDLE DIFFERENT EVENT TYPES
119
+ // ============================================================================
120
+
121
+ switch (eventType) {
122
+ case "push": {
123
+ // Push event: code was updated
124
+ const ref = payload.ref || "";
125
+ const branch = ref.split("/").pop();
126
+ console.log(`[Webhook] Push to ${repoName}@${branch}`);
127
+
128
+ // Only sync on pushes to main/master
129
+ if (branch !== "main" && branch !== "master") {
130
+ console.log(`[Webhook] Skipping non-main push to branch: ${branch}`);
131
+ return NextResponse.json({ status: "skipped", reason: "non-main-branch" });
132
+ }
133
+
134
+ // Queue a sync for this repo
135
+ await queueRepoSync(owner, repoShort);
136
+ break;
137
+ }
138
+
139
+ case "pull_request": {
140
+ // PR event: opened, closed, synchronize, etc.
141
+ const action = payload.action || "";
142
+ const prNumber = payload.pull_request?.number;
143
+ console.log(`[Webhook] Pull request ${action}: #${prNumber}`);
144
+
145
+ // Sync on relevant actions only
146
+ if (["opened", "closed", "reopened", "synchronize"].includes(action)) {
147
+ await queueRepoSync(owner, repoShort);
148
+ }
149
+ break;
150
+ }
151
+
152
+ case "issues": {
153
+ // Issue event: opened, closed, etc.
154
+ const action = payload.action || "";
155
+ const issueNumber = payload.issue?.number;
156
+ console.log(`[Webhook] Issue ${action}: #${issueNumber}`);
157
+
158
+ // Sync on relevant actions
159
+ if (["opened", "closed", "reopened"].includes(action)) {
160
+ await queueRepoSync(owner, repoShort);
161
+ }
162
+ break;
163
+ }
164
+
165
+ case "ping": {
166
+ // GitHub sends a ping on webhook creation to verify it's reachable
167
+ console.log("[Webhook] Ping from GitHub - webhook is reachable");
168
+ return NextResponse.json({ status: "pong" });
169
+ }
170
+
171
+ default: {
172
+ console.log(`[Webhook] Unhandled event type: ${eventType}`);
173
+ return NextResponse.json({
174
+ status: "ignored",
175
+ reason: "unhandled-event-type",
176
+ });
177
+ }
178
+ }
179
+
180
+ return NextResponse.json({ status: "ok", repo: repoName });
181
+
182
+ } catch (error) {
183
+ console.error("[Webhook] Error processing GitHub webhook:", error);
184
+ return NextResponse.json(
185
+ { error: "Failed to process webhook" },
186
+ { status: 500 }
187
+ );
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Queue a sync for a repository.
193
+ *
194
+ * ROADMAP:
195
+ * - Current: synchronous call to syncSingleRepository
196
+ * - Future: queue to Bull/BullMQ job queue for async processing
197
+ * - Eventually: trigger NextJS server action or external worker
198
+ */
199
+ async function queueRepoSync(owner: string, repo: string): Promise<void> {
200
+ try {
201
+ // Find the repository in our DB
202
+ const dbRepo = await db.select()
203
+ .from(repositories)
204
+ .where(
205
+ and(
206
+ eq(repositories.owner, owner),
207
+ like(repositories.name, `%${repo}%`)
208
+ )
209
+ )
210
+ .limit(1);
211
+
212
+ if (!dbRepo || dbRepo.length === 0) {
213
+ console.warn(
214
+ `[Webhook] Repository ${owner}/${repo} not tracked in DB. Skipping sync.`
215
+ );
216
+ return;
217
+ }
218
+
219
+ const repository = dbRepo[0];
220
+
221
+ // Get the owner's GitHub token
222
+ const user = await db.select()
223
+ .from(users)
224
+ .where(eq(users.id, repository.userId))
225
+ .limit(1);
226
+
227
+ if (!user || user.length === 0 || !user[0].githubAccessToken) {
228
+ console.warn(
229
+ `[Webhook] No GitHub token for user ${repository.userId}. Skipping sync.`
230
+ );
231
+ return;
232
+ }
233
+
234
+ const accessToken = user[0].githubAccessToken;
235
+
236
+ // ✅ Trigger sync
237
+ console.log(
238
+ `[Webhook] Syncing ${owner}/${repo} (repoId: ${repository.id})`
239
+ );
240
+
241
+ const result = await syncSingleRepository(accessToken, repository.id, owner, repo);
242
+
243
+ console.log(
244
+ `[Webhook] Sync complete: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted`
245
+ );
246
+
247
+ // TODO: In production, queue this instead of awaiting synchronously
248
+ // await syncQueue.add("syncRepo", { repoId: repository.id, owner, repo }, { attempts: 3 });
249
+
250
+ } catch (error) {
251
+ console.error(
252
+ `[Webhook] Error queuing sync for ${owner}/${repo}:`,
253
+ error
254
+ );
255
+ // Don't re-throw; webhook should return 200 even if sync fails
256
+ // The next polling cycle will catch any missed updates
257
+ }
258
+ }
src/db/schema.ts CHANGED
@@ -61,7 +61,8 @@ export const issues = sqliteTable("issues", {
61
  githubIssueId: integer("github_issue_id").notNull(),
62
  number: integer("number").notNull(),
63
  title: text("title").notNull(),
64
- body: text("body"),
 
65
  authorName: text("author_name").notNull(),
66
  repoId: text("repo_id").notNull().references(() => repositories.id),
67
  repoName: text("repo_name").notNull(),
@@ -71,6 +72,8 @@ export const issues = sqliteTable("issues", {
71
  state: text("state").notNull().default("open"),
72
  isPR: integer("is_pr", { mode: "boolean" }).notNull().default(false),
73
  authorAssociation: text("author_association"), // OWNER, MEMBER, COLLABORATOR, etc.
 
 
74
  createdAt: text("created_at").notNull(),
75
  });
76
 
@@ -82,6 +85,8 @@ export const triageData = sqliteTable("triage_data", {
82
  summary: text("summary").notNull(),
83
  suggestedLabel: text("suggested_label").notNull(),
84
  sentiment: text("sentiment").notNull(), // Sentiment enum
 
 
85
  analyzedAt: text("analyzed_at").notNull(),
86
  });
87
 
 
61
  githubIssueId: integer("github_issue_id").notNull(),
62
  number: integer("number").notNull(),
63
  title: text("title").notNull(),
64
+ body: text("body"), // Full body fetched on-demand via /api/issues/:id/details
65
+ bodySummary: text("body_summary"), // First 200 chars of body for display optimization
66
  authorName: text("author_name").notNull(),
67
  repoId: text("repo_id").notNull().references(() => repositories.id),
68
  repoName: text("repo_name").notNull(),
 
72
  state: text("state").notNull().default("open"),
73
  isPR: integer("is_pr", { mode: "boolean" }).notNull().default(false),
74
  authorAssociation: text("author_association"), // OWNER, MEMBER, COLLABORATOR, etc.
75
+ headSha: text("head_sha"), // For PRs: track the commit SHA to detect force-pushes
76
+ updatedAt: text("updated_at"), // Track last GitHub update time for efficient syncing
77
  createdAt: text("created_at").notNull(),
78
  });
79
 
 
85
  summary: text("summary").notNull(),
86
  suggestedLabel: text("suggested_label").notNull(),
87
  sentiment: text("sentiment").notNull(), // Sentiment enum
88
+ bugRiskScore: integer("bug_risk_score"), // 0-10 risk from Quality Assessment
89
+ toxicityFlag: integer("toxicity_flag", { mode: "boolean" }).default(false), // From Bilingual Moderation
90
  analyzedAt: text("analyzed_at").notNull(),
91
  });
92
 
src/lib/db/queries/issues.ts CHANGED
@@ -1,12 +1,14 @@
1
  /**
2
- * Issue Queries - Drizzle ORM
3
- *
4
  * All issue and triage-related database operations.
 
 
5
  */
6
 
7
  import { db } from "@/db";
8
  import { issues, triageData, repositories } from "@/db/schema";
9
- import { eq, and, desc, asc, count, or, like, sql } from "drizzle-orm";
10
  import { v4 as uuidv4 } from "uuid";
11
 
12
  // =============================================================================
@@ -28,6 +30,7 @@ export async function createIssue(data: {
28
  number: number;
29
  title: string;
30
  body?: string;
 
31
  authorName: string;
32
  repoId: string;
33
  repoName: string;
@@ -36,9 +39,15 @@ export async function createIssue(data: {
36
  htmlUrl?: string;
37
  state?: string;
38
  isPR?: boolean;
 
 
39
  }) {
40
  const id = uuidv4();
41
  const now = new Date().toISOString();
 
 
 
 
42
 
43
  await db.insert(issues).values({
44
  id,
@@ -46,6 +55,7 @@ export async function createIssue(data: {
46
  number: data.number,
47
  title: data.title,
48
  body: data.body || null,
 
49
  authorName: data.authorName,
50
  repoId: data.repoId,
51
  repoName: data.repoName,
@@ -54,14 +64,16 @@ export async function createIssue(data: {
54
  htmlUrl: data.htmlUrl || null,
55
  state: data.state || "open",
56
  isPR: data.isPR || false,
 
 
57
  createdAt: now,
58
  }).onConflictDoNothing();
59
 
60
- return { id, ...data, createdAt: now };
61
  }
62
 
63
  export async function updateIssueState(id: string, state: string) {
64
- await db.update(issues).set({ state }).where(eq(issues.id, id));
65
  }
66
 
67
  export async function getIssueByNumberAndRepo(number: number, repoId: string) {
@@ -97,7 +109,7 @@ export async function cleanupDuplicateIssues() {
97
  }
98
 
99
  // =============================================================================
100
- // Issue Listing with Pagination
101
  // =============================================================================
102
 
103
  export interface IssueFilters {
@@ -110,8 +122,15 @@ export interface IssueFilters {
110
  search?: string;
111
  }
112
 
 
 
 
 
 
113
  export async function getIssues(filters: IssueFilters, page = 1, limit = 10) {
114
- const offset = (page - 1) * limit;
 
 
115
 
116
  // Build conditions
117
  const conditions = [];
@@ -123,7 +142,7 @@ export async function getIssues(filters: IssueFilters, page = 1, limit = 10) {
123
  if (filters.search) {
124
  conditions.push(or(
125
  like(issues.title, `%${filters.search}%`),
126
- like(issues.body, `%${filters.search}%`)
127
  ));
128
  }
129
 
@@ -133,69 +152,121 @@ export async function getIssues(filters: IssueFilters, page = 1, limit = 10) {
133
  .from(repositories)
134
  .where(and(
135
  eq(repositories.userId, filters.userId),
136
- eq(repositories.addedByUser, true) // Only repos explicitly added by maintainer
137
  ));
138
  const repoIds = userRepos.map(r => r.id);
139
 
140
  if (repoIds.length > 0) {
141
- conditions.push(sql`${issues.repoId} IN (${sql.join(repoIds.map(id => sql`${id}`), sql`, `)})`);
142
  } else {
143
- return { issues: [], total: 0, page, limit, totalPages: 0 };
144
  }
145
  }
146
 
147
  const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
148
 
149
- const rawResults = await db.select()
 
150
  .from(issues)
151
- .where(whereClause)
152
- .orderBy(desc(issues.createdAt));
153
-
154
- // Deduplicate by number+repoId — the true unique identifier for a PR/issue.
155
- // Duplicates exist because: (1) the primary key is a UUID so onConflictDoNothing()
156
- // never triggers, and (2) GitHub's Pulls API and Issues API return different `.id`
157
- // values for the same PR, so githubIssueId-based checks miss duplicates.
158
- const seen = new Set<string>();
159
- const deduped = rawResults.filter(issue => {
160
- const key = `${issue.repoId}:${issue.number}`;
161
- if (seen.has(key)) return false;
162
- seen.add(key);
163
- return true;
164
- });
165
 
166
- const total = deduped.length;
167
- const results = deduped.slice(offset, offset + limit);
 
 
 
 
 
168
 
169
  return {
170
  issues: results,
171
  total,
172
- page,
173
  limit,
174
- totalPages: Math.ceil(total / limit),
175
  };
176
  }
177
 
178
  // =============================================================================
179
- // Issues with Triage Data
180
  // =============================================================================
181
 
 
 
 
 
182
  export async function getIssuesWithTriage(filters: IssueFilters, page = 1, limit = 10) {
183
- const { issues: issueList, total, totalPages } = await getIssues(filters, page, limit);
 
 
184
 
185
- // Fetch triage data for each issue
186
- const issuesWithTriage = await Promise.all(issueList.map(async (issue) => {
187
- const triage = await db.select()
188
- .from(triageData)
189
- .where(eq(triageData.issueId, issue.id))
190
- .limit(1);
 
 
 
 
 
 
 
191
 
192
- return {
193
- ...issue,
194
- triage: triage[0] || null,
195
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  }));
197
 
198
- return { issues: issuesWithTriage, total, page, limit, totalPages };
 
 
 
 
 
 
199
  }
200
 
201
  // =============================================================================
 
1
  /**
2
+ * Issue Queries - Drizzle ORM (REFACTORED)
3
+ *
4
  * All issue and triage-related database operations.
5
+ * REFACTOR: SQL-first pagination using LIMIT/OFFSET, JOINs for triage data.
6
+ * This eliminates N+1 queries and in-memory deduplication.
7
  */
8
 
9
  import { db } from "@/db";
10
  import { issues, triageData, repositories } from "@/db/schema";
11
+ import { eq, and, desc, asc, count, or, like, sql, leftJoin } from "drizzle-orm";
12
  import { v4 as uuidv4 } from "uuid";
13
 
14
  // =============================================================================
 
30
  number: number;
31
  title: string;
32
  body?: string;
33
+ bodySummary?: string;
34
  authorName: string;
35
  repoId: string;
36
  repoName: string;
 
39
  htmlUrl?: string;
40
  state?: string;
41
  isPR?: boolean;
42
+ headSha?: string; // For PRs
43
+ updatedAt?: string; // From GitHub's updated_at
44
  }) {
45
  const id = uuidv4();
46
  const now = new Date().toISOString();
47
+
48
+ // Auto-generate bodySummary if not provided
49
+ const summary = data.bodySummary ||
50
+ (data.body ? data.body.substring(0, 200) : null);
51
 
52
  await db.insert(issues).values({
53
  id,
 
55
  number: data.number,
56
  title: data.title,
57
  body: data.body || null,
58
+ bodySummary: summary,
59
  authorName: data.authorName,
60
  repoId: data.repoId,
61
  repoName: data.repoName,
 
64
  htmlUrl: data.htmlUrl || null,
65
  state: data.state || "open",
66
  isPR: data.isPR || false,
67
+ headSha: data.headSha || null,
68
+ updatedAt: data.updatedAt || now,
69
  createdAt: now,
70
  }).onConflictDoNothing();
71
 
72
+ return { id, ...data, bodySummary: summary, createdAt: now };
73
  }
74
 
75
  export async function updateIssueState(id: string, state: string) {
76
+ await db.update(issues).set({ state, updatedAt: new Date().toISOString() }).where(eq(issues.id, id));
77
  }
78
 
79
  export async function getIssueByNumberAndRepo(number: number, repoId: string) {
 
109
  }
110
 
111
  // =============================================================================
112
+ // Issue Listing with SQL Pagination (REFACTORED)
113
  // =============================================================================
114
 
115
  export interface IssueFilters {
 
122
  search?: string;
123
  }
124
 
125
+ /**
126
+ * Pagination using SQL LIMIT/OFFSET.
127
+ * ✅ Never fetches all rows into memory
128
+ * ✅ Uses database-level LIMIT/OFFSET, not JS slice()
129
+ */
130
  export async function getIssues(filters: IssueFilters, page = 1, limit = 10) {
131
+ // Validate page and limit
132
+ const offset = Math.max(0, (page - 1) * limit);
133
+ const safePage = Math.max(1, page);
134
 
135
  // Build conditions
136
  const conditions = [];
 
142
  if (filters.search) {
143
  conditions.push(or(
144
  like(issues.title, `%${filters.search}%`),
145
+ like(issues.bodySummary, `%${filters.search}%`) // Changed from body to bodySummary for performance
146
  ));
147
  }
148
 
 
152
  .from(repositories)
153
  .where(and(
154
  eq(repositories.userId, filters.userId),
155
+ eq(repositories.addedByUser, true)
156
  ));
157
  const repoIds = userRepos.map(r => r.id);
158
 
159
  if (repoIds.length > 0) {
160
+ conditions.push(sql`${issues.repoId} IN (${sql.join(repoIds.map(id => sql`'${id}'`), sql`, `)})`);
161
  } else {
162
+ return { issues: [], total: 0, page: safePage, limit, totalPages: 0 };
163
  }
164
  }
165
 
166
  const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
167
 
168
+ // Get total count
169
+ const countResult = await db.select({ count: count() })
170
  .from(issues)
171
+ .where(whereClause);
172
+ const total = countResult[0]?.count || 0;
173
+ const totalPages = Math.ceil(total / limit);
 
 
 
 
 
 
 
 
 
 
 
174
 
175
+ // SQL-level LIMIT/OFFSET — never materializes full result
176
+ const results = await db.select()
177
+ .from(issues)
178
+ .where(whereClause)
179
+ .orderBy(desc(issues.createdAt))
180
+ .limit(limit)
181
+ .offset(offset);
182
 
183
  return {
184
  issues: results,
185
  total,
186
+ page: safePage,
187
  limit,
188
+ totalPages,
189
  };
190
  }
191
 
192
  // =============================================================================
193
+ // Issues with Triage Data (REFACTORED - Single Query with JOIN)
194
  // =============================================================================
195
 
196
+ /**
197
+ * ✅ REFACTORED: Single SQL JOIN instead of Promise.all().
198
+ * Eliminates N+1 query problem. Database joins and returns in one round-trip.
199
+ */
200
  export async function getIssuesWithTriage(filters: IssueFilters, page = 1, limit = 10) {
201
+ // Validate pagination
202
+ const offset = Math.max(0, (page - 1) * limit);
203
+ const safePage = Math.max(1, page);
204
 
205
+ // Build conditions (identical to getIssues)
206
+ const conditions = [];
207
+ if (filters.repoId) conditions.push(eq(issues.repoId, filters.repoId));
208
+ if (filters.repoName) conditions.push(eq(issues.repoName, filters.repoName));
209
+ if (filters.authorName) conditions.push(eq(issues.authorName, filters.authorName));
210
+ if (filters.state) conditions.push(eq(issues.state, filters.state));
211
+ if (filters.isPR !== undefined) conditions.push(eq(issues.isPR, filters.isPR));
212
+ if (filters.search) {
213
+ conditions.push(or(
214
+ like(issues.title, `%${filters.search}%`),
215
+ like(issues.bodySummary, `%${filters.search}%`)
216
+ ));
217
+ }
218
 
219
+ if (filters.userId) {
220
+ const userRepos = await db.select({ id: repositories.id })
221
+ .from(repositories)
222
+ .where(and(
223
+ eq(repositories.userId, filters.userId),
224
+ eq(repositories.addedByUser, true)
225
+ ));
226
+ const repoIds = userRepos.map(r => r.id);
227
+
228
+ if (repoIds.length > 0) {
229
+ conditions.push(sql`${issues.repoId} IN (${sql.join(repoIds.map(id => sql`'${id}'`), sql`, `)})`);
230
+ } else {
231
+ return { issues: [], total: 0, page: safePage, limit, totalPages: 0 };
232
+ }
233
+ }
234
+
235
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
236
+
237
+ // Get total count
238
+ const countResult = await db.select({ count: count() })
239
+ .from(issues)
240
+ .where(whereClause);
241
+ const total = countResult[0]?.count || 0;
242
+ const totalPages = Math.ceil(total / limit);
243
+
244
+ // ✅ Single JOIN query: issues LEFT JOIN triageData
245
+ // Database executes this in one round-trip, no N+1
246
+ const results = await db.select({
247
+ issue: issues,
248
+ triage: triageData,
249
+ })
250
+ .from(issues)
251
+ .leftJoin(triageData, eq(triageData.issueId, issues.id))
252
+ .where(whereClause)
253
+ .orderBy(desc(issues.createdAt))
254
+ .limit(limit)
255
+ .offset(offset);
256
+
257
+ // Transform [ {issue, triage}, {issue, triage}, ... ] into [ {issue, triage}, ... ]
258
+ const issuesWithTriage = results.map(row => ({
259
+ ...row.issue,
260
+ triage: row.triage || null,
261
  }));
262
 
263
+ return {
264
+ issues: issuesWithTriage,
265
+ total,
266
+ page: safePage,
267
+ limit,
268
+ totalPages,
269
+ };
270
  }
271
 
272
  // =============================================================================
src/lib/sync/github-sync.refactor.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * REFACTORED: Sync Engine Enhancement
3
+ *
4
+ * This snippet shows the enhanced reconciliation logic with SHA/updated_at checking.
5
+ * Replace the existing reconciliation section in syncRepository() with this.
6
+ *
7
+ * KEY IMPROVEMENT:
8
+ * - Before expensive field-level comparisons, check GitHub's updated_at timestamp
9
+ * - If the timestamp hasn't changed, skip the update entirely
10
+ * - For PRs, also track headSha to detect force-pushes
11
+ */
12
+
13
+ export {};
14
+
15
+ /*
16
+ // ============================================================================
17
+ // Enhanced Reconciliation: SHA/Updated_at Checks (REFACTORED)
18
+ // ============================================================================
19
+
20
+ // In the process open items loop, replace the update logic with this:
21
+
22
+ for (const ghItem of openItems) {
23
+ openNumbers.add(ghItem.number);
24
+ checkMentorStatus(ghItem);
25
+
26
+ if (!shouldIncludeItem(ghItem)) {
27
+ continue;
28
+ }
29
+
30
+ const isPR = !!ghItem.pull_request;
31
+ const existingIssue = dbIssuesByNumber.get(ghItem.number);
32
+
33
+ if (existingIssue) {
34
+ // ✅ REFACTORED: Skip update if GitHub's updated_at hasn't changed
35
+ const ghUpdatedAt = ghItem.updated_at;
36
+ const dbUpdatedAt = existingIssue.updatedAt;
37
+
38
+ // For PRs, also check the head SHA for force-push detection
39
+ const ghHeadSha = isPR ? ghItem.pull_request?.head?.sha : null;
40
+ const dbHeadSha = existingIssue.headSha;
41
+
42
+ // If the item hasn't been updated on GitHub and SHA is the same, skip entirely
43
+ const isSameTimestamp = ghUpdatedAt && dbUpdatedAt &&
44
+ new Date(ghUpdatedAt).getTime() === new Date(dbUpdatedAt).getTime();
45
+ const isSameSha = !isPR || (ghHeadSha === dbHeadSha);
46
+
47
+ if (isSameTimestamp && isSameSha) {
48
+ console.log(`[Sync] ${repoName}#${ghItem.number}: Skipped (no changes on GitHub)`);
49
+ continue; // ✅ Skip this item entirely
50
+ }
51
+
52
+ // Only update if actual field changes detected
53
+ if (existingIssue.state !== ghItem.state ||
54
+ existingIssue.title !== ghItem.title ||
55
+ existingIssue.authorAssociation !== ghItem.author_association ||
56
+ ghHeadSha !== dbHeadSha) { // Force-push detected
57
+
58
+ // Generate body summary if body changed
59
+ const newBodySummary = ghItem.body
60
+ ? ghItem.body.substring(0, 200)
61
+ : existingIssue.bodySummary;
62
+
63
+ await db.update(issues)
64
+ .set({
65
+ state: ghItem.state,
66
+ title: ghItem.title,
67
+ body: ghItem.body || null,
68
+ bodySummary: newBodySummary,
69
+ authorAssociation: ghItem.author_association,
70
+ headSha: ghHeadSha, // Track SHA for PRs
71
+ updatedAt: ghUpdatedAt || new Date().toISOString(),
72
+ })
73
+ .where(eq(issues.id, existingIssue.id));
74
+ updated++;
75
+
76
+ if (isAblyConfigured()) {
77
+ await publishIssueUpdated({
78
+ id: existingIssue.id,
79
+ githubIssueId: ghItem.id,
80
+ number: ghItem.number,
81
+ title: ghItem.title,
82
+ repoName,
83
+ owner,
84
+ repo,
85
+ isPR,
86
+ state: ghItem.state,
87
+ });
88
+ }
89
+ }
90
+ } else {
91
+ // Create new issue with all fields populated
92
+ const newId = uuidv4();
93
+ const bodySummary = ghItem.body
94
+ ? ghItem.body.substring(0, 200)
95
+ : null;
96
+
97
+ await db.insert(issues).values({
98
+ id: newId,
99
+ githubIssueId: ghItem.id,
100
+ number: ghItem.number,
101
+ title: ghItem.title,
102
+ body: ghItem.body || null,
103
+ bodySummary,
104
+ authorName: ghItem.user.login,
105
+ repoId,
106
+ repoName,
107
+ owner,
108
+ repo,
109
+ htmlUrl: ghItem.html_url,
110
+ state: ghItem.state,
111
+ isPR,
112
+ authorAssociation: ghItem.author_association,
113
+ headSha: isPR ? ghItem.pull_request?.head?.sha : null,
114
+ updatedAt: ghItem.updated_at || new Date().toISOString(),
115
+ createdAt: new Date().toISOString(),
116
+ }).onConflictDoNothing();
117
+ created++;
118
+
119
+ if (isAblyConfigured()) {
120
+ await publishIssueCreated({
121
+ id: newId,
122
+ githubIssueId: ghItem.id,
123
+ number: ghItem.number,
124
+ title: ghItem.title,
125
+ repoName,
126
+ owner,
127
+ repo,
128
+ isPR,
129
+ state: ghItem.state,
130
+ });
131
+ }
132
+ }
133
+ }
134
+ */