Spaces:
Running
Running
Commit ·
845872f
1
Parent(s): 9458a3a
apply new changes
Browse files- src/app/api/issues/[id]/details/route.ts +100 -0
- src/app/api/webhooks/github/route.ts +258 -0
- src/db/schema.ts +6 -1
- src/lib/db/queries/issues.ts +114 -43
- src/lib/sync/github-sync.refactor.ts +134 -0
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 |
-
|
|
|
|
|
|
|
| 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.
|
| 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)
|
| 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 |
-
|
|
|
|
| 150 |
.from(issues)
|
| 151 |
-
.where(whereClause)
|
| 152 |
-
|
| 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 |
-
|
| 167 |
-
const results =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
return {
|
| 170 |
issues: results,
|
| 171 |
total,
|
| 172 |
-
page,
|
| 173 |
limit,
|
| 174 |
-
totalPages
|
| 175 |
};
|
| 176 |
}
|
| 177 |
|
| 178 |
// =============================================================================
|
| 179 |
-
// Issues with Triage Data
|
| 180 |
// =============================================================================
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
export async function getIssuesWithTriage(filters: IssueFilters, page = 1, limit = 10) {
|
| 183 |
-
|
|
|
|
|
|
|
| 184 |
|
| 185 |
-
//
|
| 186 |
-
const
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
}));
|
| 197 |
|
| 198 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
*/
|