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:
2026-03-09 11:30:51 -07:00
parent 231aeb4402
commit 9c277fd8e3
3 changed files with 195 additions and 13 deletions

View File

@@ -76,6 +76,7 @@ export default function ProjectsPage() {
const [showNew, setShowNew] = useState(false); const [showNew, setShowNew] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null); const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const fetchProjects = async () => { const fetchProjects = async () => {
try { try {
@@ -193,10 +194,12 @@ export default function ProjectsPage() {
transition: "all 0.15s", transition: "all 0.15s",
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
setHoveredId(p.id);
e.currentTarget.style.borderColor = "#d0ccc4"; e.currentTarget.style.borderColor = "#d0ccc4";
e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a"; e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
setHoveredId(null);
e.currentTarget.style.borderColor = "#e8e4dc"; e.currentTarget.style.borderColor = "#e8e4dc";
e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05"; e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05";
}} }}
@@ -247,19 +250,19 @@ export default function ProjectsPage() {
</div> </div>
</div> </div>
{/* Delete (hover) */} {/* Delete (visible on row hover) */}
<button <button
onClick={(e) => { e.preventDefault(); setProjectToDelete(p); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectToDelete(p); }}
style={{ style={{
marginLeft: 16, padding: "5px 8px", borderRadius: 6, marginLeft: 16, padding: "6px 8px", borderRadius: 6,
border: "none", background: "transparent", border: "none", background: "transparent",
color: "#b5b0a6", cursor: "pointer", color: "#c0bab2", cursor: "pointer",
opacity: 0, transition: "opacity 0.15s", opacity: hoveredId === p.id ? 1 : 0,
fontFamily: "Outfit, sans-serif", transition: "opacity 0.15s, color 0.15s",
fontFamily: "Outfit, sans-serif", flexShrink: 0,
}} }}
className="delete-btn" onMouseEnter={(e) => { e.currentTarget.style.color = "#d32f2f"; }}
onMouseEnter={(e) => e.currentTarget.style.color = "#d32f2f"} onMouseLeave={(e) => { e.currentTarget.style.color = "#c0bab2"; }}
onMouseLeave={(e) => e.currentTarget.style.color = "#b5b0a6"}
title="Delete project" title="Delete project"
> >
<Trash2 style={{ width: 14, height: 14 }} /> <Trash2 style={{ width: 14, height: 14 }} />

View 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 }
);
}
}

View File

@@ -115,9 +115,28 @@ export async function POST(request: Request) {
giteaCloneUrl = repo.clone_url; giteaCloneUrl = repo.clone_url;
giteaSshUrl = repo.ssh_url; giteaSshUrl = repo.ssh_url;
// Push Turborepo monorepo scaffold as initial commit // 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); await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName);
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`); console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
}
// Register webhook — skip if one already points to this project // Register webhook — skip if one already points to this project
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`; 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 // Turborepo monorepo apps — each gets its own Coolify service
turboVersion: '2.3.3', turboVersion: '2.3.3',
apps: provisionedApps, apps: provisionedApps,
// Import metadata
isImport: !!githubRepoUrl,
importAnalysisStatus: githubRepoUrl ? 'pending' : null,
importAnalysisJobId: null as string | null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -262,7 +285,40 @@ export async function POST(request: Request) {
`, [JSON.stringify(projectId), firebaseUserId, workspacePath]); `, [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({ return NextResponse.json({
success: true, success: true,
@@ -275,6 +331,8 @@ export async function POST(request: Request) {
giteaError: giteaError ?? undefined, giteaError: giteaError ?? undefined,
theiaWorkspaceUrl, theiaWorkspaceUrl,
theiaError: theiaError ?? undefined, theiaError: theiaError ?? undefined,
isImport: !!githubRepoUrl,
analysisJobId: analysisJobId ?? undefined,
}); });
} catch (error) { } catch (error) {
console.error('[POST /api/projects/create] Error:', error); console.error('[POST /api/projects/create] Error:', error);