feat: add GitHub import flow, project delete fix, and analyze API
- Mirror GitHub repos to Gitea as-is on import (skip scaffold) - Auto-trigger ImportAnalyzer agent after successful mirror - Add POST/GET /api/projects/[projectId]/analyze route - Fix project delete button visibility (was permanently opacity:0) - Store isImport, importAnalysisStatus, importAnalysisJobId on projects Made-with: Cursor
This commit is contained in:
@@ -76,6 +76,7 @@ export default function ProjectsPage() {
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
@@ -193,10 +194,12 @@ export default function ProjectsPage() {
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
setHoveredId(p.id);
|
||||
e.currentTarget.style.borderColor = "#d0ccc4";
|
||||
e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
setHoveredId(null);
|
||||
e.currentTarget.style.borderColor = "#e8e4dc";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05";
|
||||
}}
|
||||
@@ -247,19 +250,19 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete (hover) */}
|
||||
{/* Delete (visible on row hover) */}
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); setProjectToDelete(p); }}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectToDelete(p); }}
|
||||
style={{
|
||||
marginLeft: 16, padding: "5px 8px", borderRadius: 6,
|
||||
marginLeft: 16, padding: "6px 8px", borderRadius: 6,
|
||||
border: "none", background: "transparent",
|
||||
color: "#b5b0a6", cursor: "pointer",
|
||||
opacity: 0, transition: "opacity 0.15s",
|
||||
fontFamily: "Outfit, sans-serif",
|
||||
color: "#c0bab2", cursor: "pointer",
|
||||
opacity: hoveredId === p.id ? 1 : 0,
|
||||
transition: "opacity 0.15s, color 0.15s",
|
||||
fontFamily: "Outfit, sans-serif", flexShrink: 0,
|
||||
}}
|
||||
className="delete-btn"
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = "#d32f2f"}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = "#b5b0a6"}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = "#d32f2f"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = "#c0bab2"; }}
|
||||
title="Delete project"
|
||||
>
|
||||
<Trash2 style={{ width: 14, height: 14 }} />
|
||||
|
||||
121
app/api/projects/[projectId]/analyze/route.ts
Normal file
121
app/api/projects/[projectId]/analyze/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth/authOptions';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
|
||||
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||
|
||||
// GET — check the current analysis status for a project
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await params;
|
||||
|
||||
const rows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
if (!rows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
|
||||
const project = rows[0].data;
|
||||
|
||||
if (!project.isImport) {
|
||||
return NextResponse.json({ isImport: false });
|
||||
}
|
||||
|
||||
const jobId = project.importAnalysisJobId;
|
||||
let jobStatus: Record<string, unknown> | null = null;
|
||||
|
||||
// Fetch live job status from agent runner if we have a job ID
|
||||
if (jobId) {
|
||||
try {
|
||||
const jobRes = await fetch(`${AGENT_RUNNER_URL}/api/jobs/${jobId}`);
|
||||
if (jobRes.ok) {
|
||||
jobStatus = await jobRes.json() as Record<string, unknown>;
|
||||
|
||||
// Sync terminal status back to the project record
|
||||
const runnerStatus = jobStatus.status as string | undefined;
|
||||
if (runnerStatus && runnerStatus !== project.importAnalysisStatus) {
|
||||
await query(
|
||||
`UPDATE fs_projects SET data = jsonb_set(data, '{importAnalysisStatus}', $1::jsonb) WHERE id = $2`,
|
||||
[JSON.stringify(runnerStatus), projectId]
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Agent runner unreachable — return last known status
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isImport: true,
|
||||
status: project.importAnalysisStatus ?? 'pending',
|
||||
jobId,
|
||||
job: jobStatus,
|
||||
githubRepoUrl: project.githubRepoUrl,
|
||||
giteaRepo: project.giteaRepo,
|
||||
});
|
||||
}
|
||||
|
||||
// POST — (re-)trigger an analysis job for a project
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await params;
|
||||
|
||||
const rows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
if (!rows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
|
||||
const project = rows[0].data;
|
||||
|
||||
if (!project.giteaRepo) {
|
||||
return NextResponse.json({ error: 'Project has no Gitea repo' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const jobRes = await fetch(`${AGENT_RUNNER_URL}/api/agent/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent: 'ImportAnalyzer',
|
||||
task: `Analyze this codebase${project.githubRepoUrl ? ` (originally from ${project.githubRepoUrl})` : ''} and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`,
|
||||
repo: project.giteaRepo,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!jobRes.ok) {
|
||||
const detail = await jobRes.text();
|
||||
return NextResponse.json({ error: 'Failed to start analysis', details: detail }, { status: 500 });
|
||||
}
|
||||
|
||||
const jobData = await jobRes.json() as { jobId?: string };
|
||||
const jobId = jobData.jobId ?? null;
|
||||
|
||||
await query(
|
||||
`UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`,
|
||||
[JSON.stringify(jobId), projectId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, jobId, status: 'running' });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to start analysis', details: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -115,9 +115,28 @@ export async function POST(request: Request) {
|
||||
giteaCloneUrl = repo.clone_url;
|
||||
giteaSshUrl = repo.ssh_url;
|
||||
|
||||
// Push Turborepo monorepo scaffold as initial commit
|
||||
await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName);
|
||||
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
|
||||
// If a GitHub repo was provided, mirror it as-is.
|
||||
// Otherwise push the default Turborepo scaffold.
|
||||
if (githubRepoUrl) {
|
||||
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||
const mirrorRes = await fetch(`${agentRunnerUrl}/api/mirror`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
github_url: githubRepoUrl,
|
||||
gitea_repo: `${GITEA_ADMIN_USER}/${repoName}`,
|
||||
project_name: projectName,
|
||||
}),
|
||||
});
|
||||
if (!mirrorRes.ok) {
|
||||
const detail = await mirrorRes.text();
|
||||
throw new Error(`GitHub mirror failed: ${detail}`);
|
||||
}
|
||||
console.log(`[API] GitHub repo mirrored to ${giteaRepo}`);
|
||||
} else {
|
||||
await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName);
|
||||
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
|
||||
}
|
||||
|
||||
// Register webhook — skip if one already points to this project
|
||||
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`;
|
||||
@@ -239,6 +258,10 @@ export async function POST(request: Request) {
|
||||
// Turborepo monorepo apps — each gets its own Coolify service
|
||||
turboVersion: '2.3.3',
|
||||
apps: provisionedApps,
|
||||
// Import metadata
|
||||
isImport: !!githubRepoUrl,
|
||||
importAnalysisStatus: githubRepoUrl ? 'pending' : null,
|
||||
importAnalysisJobId: null as string | null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -262,7 +285,40 @@ export async function POST(request: Request) {
|
||||
`, [JSON.stringify(projectId), firebaseUserId, workspacePath]);
|
||||
}
|
||||
|
||||
console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped');
|
||||
// ──────────────────────────────────────────────
|
||||
// 5. If this is an import, trigger the analysis agent
|
||||
// ──────────────────────────────────────────────
|
||||
let analysisJobId: string | null = null;
|
||||
if (githubRepoUrl && giteaRepo) {
|
||||
try {
|
||||
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||
const jobRes = await fetch(`${agentRunnerUrl}/api/agent/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent: 'ImportAnalyzer',
|
||||
task: `Analyze this imported codebase (originally from ${githubRepoUrl}) and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`,
|
||||
repo: giteaRepo,
|
||||
}),
|
||||
});
|
||||
if (jobRes.ok) {
|
||||
const jobData = await jobRes.json() as { jobId?: string };
|
||||
analysisJobId = jobData.jobId ?? null;
|
||||
// Store the job ID on the project record
|
||||
if (analysisJobId) {
|
||||
await query(
|
||||
`UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`,
|
||||
[JSON.stringify(analysisJobId), projectId]
|
||||
);
|
||||
}
|
||||
console.log(`[API] Import analysis job started: ${analysisJobId}`);
|
||||
}
|
||||
} catch (analysisErr) {
|
||||
console.error('[API] Failed to start import analysis (non-fatal):', analysisErr);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped', '| import:', !!githubRepoUrl);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -275,6 +331,8 @@ export async function POST(request: Request) {
|
||||
giteaError: giteaError ?? undefined,
|
||||
theiaWorkspaceUrl,
|
||||
theiaError: theiaError ?? undefined,
|
||||
isImport: !!githubRepoUrl,
|
||||
analysisJobId: analysisJobId ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[POST /api/projects/create] Error:', error);
|
||||
|
||||
Reference in New Issue
Block a user