Spaces:
Sleeping
Sleeping
Commit ·
8f7b71b
1
Parent(s): e31d5c3
bug fix 15
Browse files- src/app/api/sync/reconcile/route.ts +86 -0
- src/app/api/sync/run/route.ts +7 -1
- src/lib/sync/github-sync.ts +157 -0
src/app/api/sync/reconcile/route.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Issue Reconciliation API Route
|
| 3 |
+
*
|
| 4 |
+
* POST /api/sync/reconcile - Reconcile a specific issue's state
|
| 5 |
+
*
|
| 6 |
+
* Checks the issue state on GitHub and updates the database accordingly.
|
| 7 |
+
* Use this to immediately sync a specific issue without running full sync.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 11 |
+
import { getCurrentUser } from "@/lib/auth";
|
| 12 |
+
import { reconcileSingleIssue, reconcileOpenTriageIssue1 } from "@/lib/sync/github-sync";
|
| 13 |
+
|
| 14 |
+
export async function POST(request: NextRequest) {
|
| 15 |
+
try {
|
| 16 |
+
const user = await getCurrentUser(request);
|
| 17 |
+
if (!user) {
|
| 18 |
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
if (!user.githubAccessToken) {
|
| 22 |
+
return NextResponse.json({ error: "GitHub access token not found" }, { status: 400 });
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Parse request body
|
| 26 |
+
const body = await request.json().catch(() => ({}));
|
| 27 |
+
const { owner, repo, issueNumber, reconcileOpenTriage } = body;
|
| 28 |
+
|
| 29 |
+
// Option 1: Reconcile the specific openTriage#1 issue
|
| 30 |
+
if (reconcileOpenTriage) {
|
| 31 |
+
const result = await reconcileOpenTriageIssue1(user.githubAccessToken);
|
| 32 |
+
return NextResponse.json({
|
| 33 |
+
success: result.success,
|
| 34 |
+
message: result.message,
|
| 35 |
+
result,
|
| 36 |
+
});
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Option 2: Reconcile a specific issue by owner/repo/number
|
| 40 |
+
if (owner && repo && issueNumber) {
|
| 41 |
+
const result = await reconcileSingleIssue(
|
| 42 |
+
user.githubAccessToken,
|
| 43 |
+
owner,
|
| 44 |
+
repo,
|
| 45 |
+
Number(issueNumber)
|
| 46 |
+
);
|
| 47 |
+
return NextResponse.json({
|
| 48 |
+
success: result.success,
|
| 49 |
+
message: result.message,
|
| 50 |
+
result,
|
| 51 |
+
});
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return NextResponse.json({
|
| 55 |
+
error: "Missing required parameters. Provide either { reconcileOpenTriage: true } or { owner, repo, issueNumber }",
|
| 56 |
+
}, { status: 400 });
|
| 57 |
+
|
| 58 |
+
} catch (error) {
|
| 59 |
+
console.error("POST /api/sync/reconcile error:", error);
|
| 60 |
+
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export async function GET() {
|
| 65 |
+
return NextResponse.json({
|
| 66 |
+
description: "Issue Reconciliation API",
|
| 67 |
+
usage: {
|
| 68 |
+
reconcileOpenTriage: {
|
| 69 |
+
method: "POST",
|
| 70 |
+
body: { reconcileOpenTriage: true },
|
| 71 |
+
description: "Reconcile cosmicMagnetar/openTriage#1 specifically",
|
| 72 |
+
},
|
| 73 |
+
reconcileSpecific: {
|
| 74 |
+
method: "POST",
|
| 75 |
+
body: { owner: "string", repo: "string", issueNumber: "number" },
|
| 76 |
+
description: "Reconcile any specific issue by owner/repo/number",
|
| 77 |
+
},
|
| 78 |
+
},
|
| 79 |
+
behavior: [
|
| 80 |
+
"Fetches the issue state directly from GitHub API",
|
| 81 |
+
"If issue is closed/not found on GitHub, removes it from database",
|
| 82 |
+
"If issue is open, updates database to match GitHub state",
|
| 83 |
+
"Publishes real-time events via Ably if configured",
|
| 84 |
+
],
|
| 85 |
+
});
|
| 86 |
+
}
|
src/app/api/sync/run/route.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 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 {
|
|
@@ -32,11 +32,17 @@ export async function POST(request: NextRequest) {
|
|
| 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 |
});
|
| 42 |
} catch (error) {
|
|
|
|
| 7 |
|
| 8 |
import { NextRequest, NextResponse } from "next/server";
|
| 9 |
import { getCurrentUser } from "@/lib/auth";
|
| 10 |
+
import { runFullSync, runMaintainerSync, runContributorSync, reconcileOpenTriageIssue1, SYNC_INTERVAL_MS } from "@/lib/sync/github-sync";
|
| 11 |
|
| 12 |
export async function POST(request: NextRequest) {
|
| 13 |
try {
|
|
|
|
| 32 |
stats = await runContributorSync(user.id, user.username, user.githubAccessToken);
|
| 33 |
}
|
| 34 |
|
| 35 |
+
// Always reconcile the critical openTriage#1 issue to ensure immediate state sync
|
| 36 |
+
const reconcileResult = await reconcileOpenTriageIssue1(user.githubAccessToken);
|
| 37 |
+
|
| 38 |
return NextResponse.json({
|
| 39 |
success: true,
|
| 40 |
message: "Sync completed",
|
| 41 |
role: userRole,
|
| 42 |
stats,
|
| 43 |
+
reconcile: {
|
| 44 |
+
openTriageIssue1: reconcileResult,
|
| 45 |
+
},
|
| 46 |
nextSyncIntervalMs: SYNC_INTERVAL_MS,
|
| 47 |
});
|
| 48 |
} catch (error) {
|
src/lib/sync/github-sync.ts
CHANGED
|
@@ -588,3 +588,160 @@ export async function runMaintainerSync(
|
|
| 588 |
role: 'MAINTAINER',
|
| 589 |
});
|
| 590 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
role: 'MAINTAINER',
|
| 589 |
});
|
| 590 |
}
|
| 591 |
+
|
| 592 |
+
// =============================================================================
|
| 593 |
+
// Reconcile Single Issue - Check specific issue state on GitHub
|
| 594 |
+
// =============================================================================
|
| 595 |
+
|
| 596 |
+
interface IssueReconcileResult {
|
| 597 |
+
success: boolean;
|
| 598 |
+
owner: string;
|
| 599 |
+
repo: string;
|
| 600 |
+
issueNumber: number;
|
| 601 |
+
githubState: 'open' | 'closed' | 'not_found';
|
| 602 |
+
dbState: 'open' | 'closed' | 'not_found';
|
| 603 |
+
action: 'none' | 'deleted' | 'updated' | 'created';
|
| 604 |
+
message: string;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
/**
|
| 608 |
+
* Reconcile a specific issue by checking its state on GitHub
|
| 609 |
+
* and updating the database accordingly.
|
| 610 |
+
*
|
| 611 |
+
* This is useful for immediately syncing a specific issue
|
| 612 |
+
* (e.g., cosmicMagnetar/openTriage#1) without waiting for full sync.
|
| 613 |
+
*/
|
| 614 |
+
export async function reconcileSingleIssue(
|
| 615 |
+
accessToken: string,
|
| 616 |
+
owner: string,
|
| 617 |
+
repo: string,
|
| 618 |
+
issueNumber: number
|
| 619 |
+
): Promise<IssueReconcileResult> {
|
| 620 |
+
const octokit = new Octokit({ auth: accessToken });
|
| 621 |
+
const repoName = `${owner}/${repo}`;
|
| 622 |
+
|
| 623 |
+
try {
|
| 624 |
+
// 1. Check issue state on GitHub
|
| 625 |
+
let githubIssue: GitHubIssue | null = null;
|
| 626 |
+
let githubState: 'open' | 'closed' | 'not_found' = 'not_found';
|
| 627 |
+
|
| 628 |
+
try {
|
| 629 |
+
const response = await octokit.issues.get({
|
| 630 |
+
owner,
|
| 631 |
+
repo,
|
| 632 |
+
issue_number: issueNumber,
|
| 633 |
+
});
|
| 634 |
+
githubIssue = response.data as GitHubIssue;
|
| 635 |
+
githubState = githubIssue.state as 'open' | 'closed';
|
| 636 |
+
} catch (error: any) {
|
| 637 |
+
if (error.status === 404) {
|
| 638 |
+
githubState = 'not_found';
|
| 639 |
+
} else {
|
| 640 |
+
throw error;
|
| 641 |
+
}
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
// 2. Check current state in database
|
| 645 |
+
const dbIssue = await db.select()
|
| 646 |
+
.from(issues)
|
| 647 |
+
.where(
|
| 648 |
+
and(
|
| 649 |
+
eq(issues.owner, owner),
|
| 650 |
+
eq(issues.repo, repo),
|
| 651 |
+
eq(issues.number, issueNumber)
|
| 652 |
+
)
|
| 653 |
+
)
|
| 654 |
+
.limit(1);
|
| 655 |
+
|
| 656 |
+
const existingIssue = dbIssue[0];
|
| 657 |
+
const dbState: 'open' | 'closed' | 'not_found' = existingIssue
|
| 658 |
+
? (existingIssue.state as 'open' | 'closed')
|
| 659 |
+
: 'not_found';
|
| 660 |
+
|
| 661 |
+
// 3. Reconcile based on states
|
| 662 |
+
let action: 'none' | 'deleted' | 'updated' | 'created' = 'none';
|
| 663 |
+
let message = '';
|
| 664 |
+
|
| 665 |
+
if (githubState === 'closed' || githubState === 'not_found') {
|
| 666 |
+
// Issue is closed or doesn't exist on GitHub - delete from DB
|
| 667 |
+
if (existingIssue) {
|
| 668 |
+
await db.delete(issues).where(eq(issues.id, existingIssue.id));
|
| 669 |
+
action = 'deleted';
|
| 670 |
+
message = `Issue #${issueNumber} is ${githubState} on GitHub - removed from database`;
|
| 671 |
+
|
| 672 |
+
// Publish deletion event
|
| 673 |
+
if (isAblyConfigured()) {
|
| 674 |
+
await publishIssueDeleted({
|
| 675 |
+
id: existingIssue.id,
|
| 676 |
+
githubIssueId: existingIssue.githubIssueId,
|
| 677 |
+
number: issueNumber,
|
| 678 |
+
title: existingIssue.title,
|
| 679 |
+
repoName,
|
| 680 |
+
owner,
|
| 681 |
+
repo,
|
| 682 |
+
isPR: existingIssue.isPR,
|
| 683 |
+
state: 'closed',
|
| 684 |
+
});
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
console.log(`[Reconcile] ${repoName}#${issueNumber}: Deleted (${githubState} on GitHub)`);
|
| 688 |
+
} else {
|
| 689 |
+
message = `Issue #${issueNumber} is ${githubState} on GitHub and not in database - no action needed`;
|
| 690 |
+
}
|
| 691 |
+
} else if (githubState === 'open' && githubIssue) {
|
| 692 |
+
// Issue is open on GitHub
|
| 693 |
+
if (existingIssue) {
|
| 694 |
+
// Update existing issue
|
| 695 |
+
if (existingIssue.state !== 'open' || existingIssue.title !== githubIssue.title) {
|
| 696 |
+
await db.update(issues)
|
| 697 |
+
.set({
|
| 698 |
+
state: 'open',
|
| 699 |
+
title: githubIssue.title,
|
| 700 |
+
body: githubIssue.body || null,
|
| 701 |
+
})
|
| 702 |
+
.where(eq(issues.id, existingIssue.id));
|
| 703 |
+
action = 'updated';
|
| 704 |
+
message = `Issue #${issueNumber} updated to match GitHub state`;
|
| 705 |
+
} else {
|
| 706 |
+
message = `Issue #${issueNumber} is already in sync`;
|
| 707 |
+
}
|
| 708 |
+
} else {
|
| 709 |
+
// Issue is open on GitHub but not in DB - would need repo context to create
|
| 710 |
+
message = `Issue #${issueNumber} is open on GitHub but not tracked. Run full sync to add it.`;
|
| 711 |
+
}
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
return {
|
| 715 |
+
success: true,
|
| 716 |
+
owner,
|
| 717 |
+
repo,
|
| 718 |
+
issueNumber,
|
| 719 |
+
githubState,
|
| 720 |
+
dbState,
|
| 721 |
+
action,
|
| 722 |
+
message,
|
| 723 |
+
};
|
| 724 |
+
|
| 725 |
+
} catch (error) {
|
| 726 |
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
| 727 |
+
console.error(`[Reconcile] Error for ${repoName}#${issueNumber}:`, errorMessage);
|
| 728 |
+
return {
|
| 729 |
+
success: false,
|
| 730 |
+
owner,
|
| 731 |
+
repo,
|
| 732 |
+
issueNumber,
|
| 733 |
+
githubState: 'not_found',
|
| 734 |
+
dbState: 'not_found',
|
| 735 |
+
action: 'none',
|
| 736 |
+
message: `Error reconciling issue: ${errorMessage}`,
|
| 737 |
+
};
|
| 738 |
+
}
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
/**
|
| 742 |
+
* Reconcile the critical tracked issue: cosmicMagnetar/openTriage#1
|
| 743 |
+
* This ensures the issue state is immediately synced from GitHub.
|
| 744 |
+
*/
|
| 745 |
+
export async function reconcileOpenTriageIssue1(accessToken: string): Promise<IssueReconcileResult> {
|
| 746 |
+
return reconcileSingleIssue(accessToken, 'cosmicMagnetar', 'openTriage', 1);
|
| 747 |
+
}
|