Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions .github/workflows/pr-star-required.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions src/app/api/issues/[id]/activities/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
154 changes: 154 additions & 0 deletions src/app/api/issues/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {};

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 });
}
}
120 changes: 120 additions & 0 deletions src/app/api/projects/[id]/issues/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading
Loading