diff --git a/.github/workflows/pr-star-required.yml b/.github/workflows/pr-star-required.yml index 53ce21220..9cafeaf90 100644 --- a/.github/workflows/pr-star-required.yml +++ b/.github/workflows/pr-star-required.yml @@ -24,14 +24,23 @@ jobs: const repoUrl = `https://github.com/${owner}/${repo}`; async function hasStarred(username) { - const login = username.toLowerCase(); - const stargazers = await github.paginate( - github.rest.activity.listStargazersForRepo, - { owner, repo, per_page: 100 }, - ); - return stargazers.some(u => u.login.toLowerCase() === login); + const targetRepo = `${owner}/${repo}`.toLowerCase(); + try { + for await (const { data: starredRepos } of github.paginate.iterator( + github.rest.activity.listReposStarredByUser, + { username, per_page: 100 } + )) { + if (starredRepos.some(r => r.full_name.toLowerCase() === targetRepo)) { + return true; + } + } + } catch (e) { + console.error(`Error checking stars for user ${username}:`, e); + } + return false; } + async function setStatus(sha, state, description) { await github.rest.repos.createCommitStatus({ owner, repo, sha, context: CONTEXT, state, description, diff --git a/src/app/api/issues/[id]/activities/route.ts b/src/app/api/issues/[id]/activities/route.ts new file mode 100644 index 000000000..0548e5378 --- /dev/null +++ b/src/app/api/issues/[id]/activities/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id } = await params; + + // Verify issue ownership via project + const { data: issue, error: issueError } = await supabaseAdmin + .from("devtrack_issues") + .select("id, devtrack_projects(user_id)") + .eq("id", id) + .single(); + + if (issueError || !issue) { + return NextResponse.json({ error: "Issue not found" }, { status: 404 }); + } + + const project = issue.devtrack_projects as any; + if (project.user_id !== user.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { content } = await req.json(); + + if (!content || typeof content !== "string" || content.trim() === "") { + return NextResponse.json({ error: "Comment content is required" }, { status: 400 }); + } + + const { data: activity, error: insertError } = await supabaseAdmin + .from("devtrack_issue_activities") + .insert({ + issue_id: id, + type: "comment", + content: content.trim(), + author_name: session.githubLogin || "User", + }) + .select("*") + .single(); + + if (insertError) { + console.error("Failed to insert comment activity:", insertError); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json(activity, { status: 201 }); + } catch (err) { + console.error("Error creating comment:", err); + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } +} diff --git a/src/app/api/issues/[id]/route.ts b/src/app/api/issues/[id]/route.ts new file mode 100644 index 000000000..335684f8f --- /dev/null +++ b/src/app/api/issues/[id]/route.ts @@ -0,0 +1,154 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function GET(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id } = await params; + + // Fetch issue and project context to verify ownership + const { data: issue, error: issueError } = await supabaseAdmin + .from("devtrack_issues") + .select("*, devtrack_projects(user_id, key, name)") + .eq("id", id) + .single(); + + if (issueError || !issue) { + return NextResponse.json({ error: "Issue not found" }, { status: 404 }); + } + + // Cast because of nested join type mapping + const project = issue.devtrack_projects as any; + if (project.user_id !== user.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Fetch activities + const { data: activities, error: actError } = await supabaseAdmin + .from("devtrack_issue_activities") + .select("*") + .eq("issue_id", id) + .order("created_at", { ascending: true }); + + if (actError) { + console.error("Failed to fetch issue activities:", actError); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + // Clean nested properties for client response + const { devtrack_projects, ...cleanIssue } = issue; + + return NextResponse.json({ + issue: cleanIssue, + project: { + id: issue.project_id, + name: project.name, + key: project.key, + }, + activities: activities || [], + }); +} + +export async function PATCH(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id } = await params; + + // Fetch existing issue to verify ownership and check status change + const { data: existingIssue, error: fetchError } = await supabaseAdmin + .from("devtrack_issues") + .select("*, devtrack_projects(user_id)") + .eq("id", id) + .single(); + + if (fetchError || !existingIssue) { + return NextResponse.json({ error: "Issue not found" }, { status: 404 }); + } + + const project = existingIssue.devtrack_projects as any; + if (project.user_id !== user.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { title, description, status } = await req.json(); + const updates: Record = {}; + + if (title !== undefined) { + if (typeof title !== "string" || title.trim() === "") { + return NextResponse.json({ error: "Issue title cannot be empty" }, { status: 400 }); + } + updates.title = title.trim(); + } + + if (description !== undefined) { + updates.description = (description || "").trim(); + } + + let statusChanged = false; + let oldStatus = existingIssue.status; + if (status !== undefined) { + const validStatuses = ["Backlog", "Todo", "In Progress", "In Review", "Done"]; + if (!validStatuses.includes(status)) { + return NextResponse.json({ error: "Invalid status value" }, { status: 400 }); + } + if (status !== oldStatus) { + updates.status = status; + statusChanged = true; + } + } + + updates.updated_at = new Date().toISOString(); + + const { data: updatedIssue, error: updateError } = await supabaseAdmin + .from("devtrack_issues") + .update(updates) + .eq("id", id) + .select("*") + .single(); + + if (updateError) { + console.error("Failed to update issue:", updateError); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + // Insert status change activity + if (statusChanged) { + await supabaseAdmin.from("devtrack_issue_activities").insert({ + issue_id: id, + type: "status_change", + content: `Status changed from ${oldStatus} to ${status}`, + }); + } + + return NextResponse.json(updatedIssue); + } catch (err) { + console.error("Error updating issue:", err); + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } +} diff --git a/src/app/api/projects/[id]/issues/route.ts b/src/app/api/projects/[id]/issues/route.ts new file mode 100644 index 000000000..b90c8b0a0 --- /dev/null +++ b/src/app/api/projects/[id]/issues/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function GET(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id } = await params; + + // Verify project ownership + const { data: project, error: projectError } = await supabaseAdmin + .from("devtrack_projects") + .select("id") + .eq("id", id) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // Fetch issues + const { data: issues, error: issuesError } = await supabaseAdmin + .from("devtrack_issues") + .select("*") + .eq("project_id", id) + .order("issue_number", { ascending: true }); + + if (issuesError) { + console.error("Failed to fetch issues:", issuesError); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json({ issues: issues || [] }); +} + +export async function POST(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id } = await params; + + // Verify project ownership + const { data: project, error: projectError } = await supabaseAdmin + .from("devtrack_projects") + .select("id") + .eq("id", id) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + try { + const { title, description, status } = await req.json(); + + if (!title || typeof title !== "string" || title.trim() === "") { + return NextResponse.json({ error: "Issue title is required" }, { status: 400 }); + } + + const cleanStatus = status || "Todo"; + const validStatuses = ["Backlog", "Todo", "In Progress", "In Review", "Done"]; + if (!validStatuses.includes(cleanStatus)) { + return NextResponse.json({ error: "Invalid status value" }, { status: 400 }); + } + + // Insert issue (issue_number auto-incremented by trigger) + const { data: issue, error: issueError } = await supabaseAdmin + .from("devtrack_issues") + .insert({ + project_id: id, + title: title.trim(), + description: (description || "").trim(), + status: cleanStatus, + }) + .select("*") + .single(); + + if (issueError) { + console.error("Failed to create issue:", issueError); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + // Create activity for creation + await supabaseAdmin.from("devtrack_issue_activities").insert({ + issue_id: issue.id, + type: "status_change", + content: `Issue created and set to status: ${cleanStatus}`, + }); + + return NextResponse.json(issue, { status: 201 }); + } catch (err) { + console.error("Error creating issue:", err); + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } +} diff --git a/src/app/api/projects/[id]/repos/[repoId]/route.ts b/src/app/api/projects/[id]/repos/[repoId]/route.ts new file mode 100644 index 000000000..9feda8569 --- /dev/null +++ b/src/app/api/projects/[id]/repos/[repoId]/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +type Params = { + params: Promise<{ id: string; repoId: string }>; +}; + +export async function DELETE(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id, repoId } = await params; + + // Verify project ownership + const { data: project, error: projectError } = await supabaseAdmin + .from("devtrack_projects") + .select("id") + .eq("id", id) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // Delete repo link + const { error } = await supabaseAdmin + .from("devtrack_repositories") + .delete() + .eq("id", repoId) + .eq("project_id", id); + + if (error) { + console.error("Failed to delete repository link:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/projects/[id]/repos/route.ts b/src/app/api/projects/[id]/repos/route.ts new file mode 100644 index 000000000..275fb3213 --- /dev/null +++ b/src/app/api/projects/[id]/repos/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id } = await params; + + // Verify project ownership + const { data: project, error: projectError } = await supabaseAdmin + .from("devtrack_projects") + .select("id") + .eq("id", id) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + try { + const { repo_url } = await req.json(); + + if (!repo_url || typeof repo_url !== "string" || repo_url.trim() === "") { + return NextResponse.json({ error: "Repository URL is required" }, { status: 400 }); + } + + let url: URL; + try { + url = new URL(repo_url.trim()); + } catch { + return NextResponse.json({ error: "Invalid URL format" }, { status: 400 }); + } + + if (!["github.com", "gitlab.com"].includes(url.hostname.replace("www.", ""))) { + return NextResponse.json({ error: "Only GitHub and GitLab repositories are supported" }, { status: 400 }); + } + + const { data, error } = await supabaseAdmin + .from("devtrack_repositories") + .insert({ + project_id: id, + repo_url: repo_url.trim(), + }) + .select("*") + .single(); + + if (error) { + if (error.code === "23505") { // Unique violation + return NextResponse.json({ error: "This repository is already linked to this project." }, { status: 409 }); + } + console.error("Failed to link repository:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json(data, { status: 201 }); + } catch (err) { + console.error("Error linking repository:", err); + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } +} diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts new file mode 100644 index 000000000..6c00554d6 --- /dev/null +++ b/src/app/api/projects/[id]/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function GET(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id } = await params; + + // Fetch project + const { data: project, error: projectError } = await supabaseAdmin + .from("devtrack_projects") + .select("*") + .eq("id", id) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // Fetch linked repositories + const { data: repositories, error: reposError } = await supabaseAdmin + .from("devtrack_repositories") + .select("*") + .eq("project_id", id); + + if (reposError) { + console.error("Failed to fetch project repositories:", reposError); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json({ + project, + repositories: repositories || [], + }); +} + +export async function DELETE(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id } = await params; + + const { error } = await supabaseAdmin + .from("devtrack_projects") + .delete() + .eq("id", id) + .eq("user_id", user.id); + + if (error) { + console.error("Failed to delete project:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json({ success: true }); +} + +export async function PATCH(req: NextRequest, { params }: Params) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { id } = await params; + + try { + const { name, description, enable_keyword_triggers } = await req.json(); + const updates: Record = {}; + + if (name !== undefined) { + if (typeof name !== "string" || name.trim() === "") { + return NextResponse.json({ error: "Project name cannot be empty" }, { status: 400 }); + } + updates.name = name.trim(); + } + + if (description !== undefined) { + updates.description = (description || "").trim(); + } + + if (enable_keyword_triggers !== undefined) { + updates.enable_keyword_triggers = Boolean(enable_keyword_triggers); + } + + updates.updated_at = new Date().toISOString(); + + const { data, error } = await supabaseAdmin + .from("devtrack_projects") + .update(updates) + .eq("id", id) + .eq("user_id", user.id) + .select("*") + .single(); + + if (error) { + console.error("Failed to update project:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json(data); + } catch (err) { + console.error("Error updating project:", err); + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 000000000..b3147eded --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { data, error } = await supabaseAdmin + .from("devtrack_projects") + .select("*") + .eq("user_id", user.id) + .order("created_at", { ascending: false }); + + if (error) { + console.error("Failed to fetch projects:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json({ projects: data || [] }); +} + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + try { + const { name, key, description, enable_keyword_triggers } = await req.json(); + + if (!name || typeof name !== "string" || name.trim() === "") { + return NextResponse.json({ error: "Project name is required" }, { status: 400 }); + } + + if (!key || typeof key !== "string") { + return NextResponse.json({ error: "Project key is required" }, { status: 400 }); + } + + const cleanKey = key.trim().toUpperCase(); + const keyRegex = /^[A-Z][A-Z0-9]{1,9}$/; + if (!keyRegex.test(cleanKey)) { + return NextResponse.json( + { error: "Project key must be 2-10 alphanumeric characters starting with a letter" }, + { status: 400 } + ); + } + + // Insert project + const { data, error } = await supabaseAdmin + .from("devtrack_projects") + .insert({ + user_id: user.id, + name: name.trim(), + key: cleanKey, + description: (description || "").trim(), + enable_keyword_triggers: enable_keyword_triggers !== false, + }) + .select("*") + .single(); + + if (error) { + if (error.code === "23505") { // Unique violation + return NextResponse.json( + { error: "A project with this key already exists." }, + { status: 409 } + ); + } + console.error("Failed to create project:", error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + return NextResponse.json(data, { status: 201 }); + } catch (err) { + console.error("Error creating project:", err); + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }); + } +} diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts index a64c83aa1..d84ed0468 100644 --- a/src/app/api/webhooks/github/route.ts +++ b/src/app/api/webhooks/github/route.ts @@ -6,6 +6,7 @@ import { verifyGitHubSignature } from "@/lib/crypto"; import { logError } from "@/lib/error-handler"; import { sendSSEEvent } from "@/lib/sse"; import { invalidateUserMetricsCache } from "@/lib/metrics-cache"; +import { processCommits } from "@/lib/git-commit-linking"; export const dynamic = "force-dynamic"; @@ -18,12 +19,22 @@ const recentDeliveries = new Map(); interface GitHubPushPayload { after?: string; - commits?: Array; + commits?: Array<{ + id: string; + message: string; + url: string; + author: { + name: string; + email: string; + username?: string; + }; + }>; pusher?: { name?: string; }; repository?: { full_name?: string; + html_url?: string; }; sender?: { login?: string; @@ -133,6 +144,17 @@ export async function POST(req: NextRequest) { return NextResponse.json({ received: true }, { status: 200 }); } + // Process commit linking to DevTrack issues + const repoUrl = payload.repository?.html_url; + const commits = payload.commits; + if (repoUrl && commits && Array.isArray(commits)) { + try { + await processCommits(repoUrl, commits); + } catch (e) { + console.error("Error processing commits for GitHub webhook:", e); + } + } + let staleResult: Awaited>; try { staleResult = await markUserMetricsStale(githubLogin); diff --git a/src/app/api/webhooks/gitlab/route.ts b/src/app/api/webhooks/gitlab/route.ts new file mode 100644 index 000000000..2d4070e1e --- /dev/null +++ b/src/app/api/webhooks/gitlab/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { processCommits } from "@/lib/git-commit-linking"; + +export const dynamic = "force-dynamic"; + +interface GitLabPushPayload { + object_kind?: string; + after?: string; + project?: { + name?: string; + web_url?: string; + }; + commits?: Array<{ + id: string; + message: string; + url: string; + author: { + name: string; + email: string; + }; + }>; +} + +export async function POST(req: NextRequest) { + const secret = process.env.GITLAB_WEBHOOK_SECRET || process.env.GITHUB_WEBHOOK_SECRET; + const token = req.headers.get("x-gitlab-token"); + + if (secret && token !== secret) { + return NextResponse.json({ error: "Invalid token" }, { status: 401 }); + } + + let payload: GitLabPushPayload; + try { + const body = await req.text(); + payload = JSON.parse(body) as GitLabPushPayload; + } catch (e) { + return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); + } + + // Only handle push events + const objectKind = payload.object_kind || "push"; + if (objectKind !== "push") { + return NextResponse.json({ received: true }); + } + + const repoUrl = payload.project?.web_url; + const commits = payload.commits; + + if (repoUrl && commits && Array.isArray(commits)) { + try { + await processCommits(repoUrl, commits); + } catch (e) { + console.error("Error processing commits for GitLab webhook:", e); + return NextResponse.json( + { error: "Failed to process commits" }, + { status: 500 } + ); + } + } + + return NextResponse.json({ received: true }); +} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 6015f0138..d340b4016 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button"; import { useLocale, useTranslations } from "next-intl"; import { localeMetadata, locales, type AppLocale } from "@/i18n/config"; import Image from "next/image"; +import { FolderGit, Plus, Trash2, Link2, Unlink, Settings2, HelpCircle } from "lucide-react"; // ── Max length for the profile bio ────────────────────────────────────────── const BIO_MAX = 160; @@ -204,6 +205,21 @@ function SettingsPageContent() { const [orgAccounts, setOrgAccounts] = useState([]); const [orgsConfig, setOrgsConfig] = useState>({}); const [loadingOrgs, setLoadingOrgs] = useState(true); + + // DevTrack Projects States + const [projects, setProjects] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(true); + const [creatingProject, setCreatingProject] = useState(false); + const [newProjectName, setNewProjectName] = useState(""); + const [newProjectKey, setNewProjectKey] = useState(""); + const [newProjectDesc, setNewProjectDesc] = useState(""); + const [newProjectTriggers, setNewProjectTriggers] = useState(true); + const [selectedProject, setSelectedProject] = useState(null); + const [newRepoUrl, setNewRepoUrl] = useState(""); + const [submittingProject, setSubmittingProject] = useState(false); + const [submittingRepo, setSubmittingRepo] = useState(false); + const [deletingProjectId, setDeletingProjectId] = useState(null); + const [unlinkingRepoId, setUnlinkingRepoId] = useState(null); const [isDirty, setIsDirty] = useState(false); const [publicWidgets, setPublicWidgets] = useState(["streak", "contributions"]); const [savingWidgets, setSavingWidgets] = useState(false); @@ -406,6 +422,220 @@ function SettingsPageContent() { } }; + // Load projects on mount + useEffect(() => { + if (status !== "authenticated") return; + async function loadProjects() { + try { + setLoadingProjects(true); + const res = await fetch("/api/projects"); + if (res.ok) { + const data = await res.json(); + setProjects(data.projects || []); + } + } catch (err) { + console.error("Failed to load projects:", err); + } finally { + setLoadingProjects(false); + } + } + loadProjects(); + }, [status]); + + const handleSelectProject = async (proj: any) => { + if (selectedProject?.id === proj.id) { + setSelectedProject(null); + return; + } + + try { + const res = await fetch(`/api/projects/${proj.id}`); + if (res.ok) { + const data = await res.json(); + setSelectedProject({ + ...proj, + repositories: data.repositories || [] + }); + } else { + setSelectedProject({ + ...proj, + repositories: [] + }); + } + } catch (err) { + console.error("Failed to load project details:", err); + setSelectedProject({ + ...proj, + repositories: [] + }); + } + }; + + const handleCreateProject = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newProjectName.trim() || !newProjectKey.trim()) { + toast.error("Project name and key are required."); + return; + } + + setSubmittingProject(true); + try { + const res = await fetch("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: newProjectName.trim(), + key: newProjectKey.trim(), + description: newProjectDesc.trim(), + enable_keyword_triggers: newProjectTriggers, + }), + }); + + const data = await res.json(); + if (res.ok) { + setProjects((prev) => [data, ...prev]); + setNewProjectName(""); + setNewProjectKey(""); + setNewProjectDesc(""); + setNewProjectTriggers(true); + setCreatingProject(false); + toast.success("Project created successfully!"); + } else { + toast.error(data.error || "Failed to create project."); + } + } catch (err) { + console.error("Error creating project:", err); + toast.error("Failed to create project."); + } finally { + setSubmittingProject(false); + } + }; + + const handleDeleteProject = async (projId: string) => { + if (!window.confirm("Are you sure you want to delete this project? This will also remove all repository associations and issue tracking data.")) { + return; + } + + setDeletingProjectId(projId); + try { + const res = await fetch(`/api/projects/${projId}`, { + method: "DELETE", + }); + + if (res.ok) { + setProjects((prev) => prev.filter((p) => p.id !== projId)); + if (selectedProject?.id === projId) { + setSelectedProject(null); + } + toast.success("Project deleted successfully."); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to delete project."); + } + } catch (err) { + console.error("Error deleting project:", err); + toast.error("Failed to delete project."); + } finally { + setDeletingProjectId(null); + } + }; + + const handleToggleProjectTriggers = async (projId: string, enabled: boolean) => { + try { + const res = await fetch(`/api/projects/${projId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enable_keyword_triggers: enabled }), + }); + + if (res.ok) { + const updated = await res.json(); + setProjects((prev) => prev.map((p) => (p.id === projId ? updated : p))); + if (selectedProject?.id === projId) { + setSelectedProject((prev: any) => prev ? { ...prev, enable_keyword_triggers: updated.enable_keyword_triggers } : null); + } + toast.success("Project triggers updated."); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to update project triggers."); + } + } catch (err) { + console.error("Error toggling project triggers:", err); + toast.error("Failed to update project triggers."); + } + }; + + const handleLinkRepo = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedProject) return; + if (!newRepoUrl.trim()) { + toast.error("Repository URL is required."); + return; + } + + setSubmittingRepo(true); + try { + const res = await fetch(`/api/projects/${selectedProject.id}/repos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ repo_url: newRepoUrl.trim() }), + }); + + const data = await res.json(); + if (res.ok) { + setSelectedProject((prev: any) => { + if (!prev) return null; + return { + ...prev, + repositories: [...(prev.repositories || []), data], + }; + }); + setNewRepoUrl(""); + toast.success("Repository linked successfully!"); + } else { + toast.error(data.error || "Failed to link repository."); + } + } catch (err) { + console.error("Error linking repository:", err); + toast.error("Failed to link repository."); + } finally { + setSubmittingRepo(false); + } + }; + + const handleUnlinkRepo = async (repoId: string) => { + if (!selectedProject) return; + if (!window.confirm("Are you sure you want to unlink this repository? New commits to it will no longer update this project's issues.")) { + return; + } + + setUnlinkingRepoId(repoId); + try { + const res = await fetch(`/api/projects/${selectedProject.id}/repos/${repoId}`, { + method: "DELETE", + }); + + if (res.ok) { + setSelectedProject((prev: any) => { + if (!prev) return null; + return { + ...prev, + repositories: (prev.repositories || []).filter((r: any) => r.id !== repoId), + }; + }); + toast.success("Repository unlinked successfully."); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to unlink repository."); + } + } catch (err) { + console.error("Error unlinking repository:", err); + toast.error("Failed to unlink repository."); + } finally { + setUnlinkingRepoId(null); + } + }; + // Load active repos for spotlight pinning useEffect(() => { if (status !== "authenticated") return; @@ -1568,6 +1798,281 @@ function SettingsPageContent() { + {/* DevTrack Projects Section */} +
+
+
+

+ + DevTrack Projects +

+

+ Link Git repositories (GitHub/GitLab) to automatically map commits to issues. +

+
+ + +
+ + {creatingProject && ( +
+

Create New Project

+
+
+ + setNewProjectName(e.target.value)} + placeholder="e.g. My Cool App" + required + className="w-full rounded-lg border border-[var(--border)] bg-[var(--control)] px-3 py-2 text-sm text-[var(--card-foreground)] focus:border-[var(--accent)] outline-none" + /> +
+
+ + setNewProjectKey(e.target.value.toUpperCase())} + placeholder="e.g. PROJ" + required + className="w-full rounded-lg border border-[var(--border)] bg-[var(--control)] px-3 py-2 text-sm text-[var(--card-foreground)] focus:border-[var(--accent)] outline-none font-mono" + /> +
+
+
+ +