diff --git a/src/app/api/agents/[agentName]/next-task/route.ts b/src/app/api/agents/[agentName]/next-task/route.ts index 78e7107..d0af996 100644 --- a/src/app/api/agents/[agentName]/next-task/route.ts +++ b/src/app/api/agents/[agentName]/next-task/route.ts @@ -11,7 +11,7 @@ import { createFollowupPrTask, createGroomTask, } from "@/lib/agent-task"; -import { isBacklogLane, isValidLane, getLaneIds } from "@/lib/lane-config"; +import { isBacklogLane, isValidLane, getLaneIds, getBacklogLane } from "@/lib/lane-config"; export async function GET( request: Request, @@ -84,7 +84,7 @@ export async function GET( const best = candidates[0].issue; const task = createGroomTask({ agentName, - lane: best.currentLane ?? "backlog", + lane: best.currentLane ?? getBacklogLane()?.id ?? "backlog", issue: { repoFullName: best.repository.fullName, number: best.number, diff --git a/src/app/api/agents/[agentName]/work-summary/route.test.ts b/src/app/api/agents/[agentName]/work-summary/route.test.ts index 7e25e52..5d986fe 100644 --- a/src/app/api/agents/[agentName]/work-summary/route.test.ts +++ b/src/app/api/agents/[agentName]/work-summary/route.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + const { mocks } = vi.hoisted(() => ({ mocks: { issueFindMany: vi.fn(), @@ -16,20 +26,97 @@ vi.mock("@/lib/prisma", () => ({ })); import { GET } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; const TEST_AGENT = "test-agent"; +function makeRequest(urlString: string, includeAuth = true) { + const headers: Record = {}; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return GET(new Request(urlString, { headers }), { + params: Promise.resolve({ agentName: TEST_AGENT }), + }); +} + +describe("GET /api/agents/[agentName]/work-summary — auth", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + mocks.issueFindMany.mockResolvedValue([]); + mocks.prFixFindMany.mockResolvedValue([]); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + false, + ); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 for bad bearer token", async () => { + const headers: Record = { Authorization: "Bearer wrong-token" }; + const res = await GET( + new Request(`http://localhost/api/agents/${TEST_AGENT}/work-summary`, { headers }), + { params: Promise.resolve({ agentName: TEST_AGENT }) }, + ); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("authorized request preserves existing summary response", async () => { + mocks.issueFindMany.mockResolvedValue([]); + mocks.prFixFindMany.mockResolvedValue([]); + + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.agentName).toBe(TEST_AGENT); + expect(body.issues).toHaveProperty("normal"); + expect(body.prFixes).toHaveProperty("normal"); + }); + + it("unauthorized request does not call prisma.issue.findMany", async () => { + await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + false, + ); + + expect(mocks.issueFindMany).not.toHaveBeenCalled(); + }); + + it("unauthorized request does not call listQueuedPrFixItems", async () => { + await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + false, + ); + + expect(mocks.prFixFindMany).not.toHaveBeenCalled(); + }); +}); + describe("GET /api/agents/[agentName]/work-summary", () => { beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); vi.clearAllMocks(); mocks.issueFindMany.mockResolvedValue([]); mocks.prFixFindMany.mockResolvedValue([]); }); it("returns agent name and empty lane counts when no issues or PR fixes exist", async () => { - const res = await GET(new Request(`http://localhost/api/agents/${TEST_AGENT}/work-summary`), { - params: Promise.resolve({ agentName: TEST_AGENT }), - }); + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + ); expect(res.status).toBe(200); const body = await res.json(); @@ -58,9 +145,9 @@ describe("GET /api/agents/[agentName]/work-summary", () => { { labels: ["status/backlog"], currentLane: "backlog" }, ]); - const res = await GET(new Request(`http://localhost/api/agents/${TEST_AGENT}/work-summary`), { - params: Promise.resolve({ agentName: TEST_AGENT }), - }); + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + ); expect(res.status).toBe(200); const body = await res.json(); @@ -75,9 +162,9 @@ describe("GET /api/agents/[agentName]/work-summary", () => { { labels: ["type/bug"], currentLane: "normal" }, ]); - const res = await GET(new Request(`http://localhost/api/agents/${TEST_AGENT}/work-summary`), { - params: Promise.resolve({ agentName: TEST_AGENT }), - }); + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + ); expect(res.status).toBe(200); const body = await res.json(); @@ -94,9 +181,9 @@ describe("GET /api/agents/[agentName]/work-summary", () => { { lane: "NORMAL", status: "BLOCKED" }, ]); - const res = await GET(new Request(`http://localhost/api/agents/${TEST_AGENT}/work-summary`), { - params: Promise.resolve({ agentName: TEST_AGENT }), - }); + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + ); expect(res.status).toBe(200); const body = await res.json(); @@ -110,9 +197,9 @@ describe("GET /api/agents/[agentName]/work-summary", () => { { lane: null, status: "QUEUED" }, ]); - const res = await GET(new Request(`http://localhost/api/agents/${TEST_AGENT}/work-summary`), { - params: Promise.resolve({ agentName: TEST_AGENT }), - }); + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + ); expect(res.status).toBe(200); const body = await res.json(); @@ -124,9 +211,9 @@ describe("GET /api/agents/[agentName]/work-summary", () => { { labels: ["status/ready"], currentLane: null }, ]); - const res = await GET(new Request(`http://localhost/api/agents/${TEST_AGENT}/work-summary`), { - params: Promise.resolve({ agentName: TEST_AGENT }), - }); + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + ); expect(res.status).toBe(200); const body = await res.json(); @@ -138,9 +225,9 @@ describe("GET /api/agents/[agentName]/work-summary", () => { { labels: ["status/done"], currentLane: "normal" }, ]); - const res = await GET(new Request(`http://localhost/api/agents/${TEST_AGENT}/work-summary`), { - params: Promise.resolve({ agentName: TEST_AGENT }), - }); + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + ); expect(res.status).toBe(200); const body = await res.json(); @@ -150,9 +237,9 @@ describe("GET /api/agents/[agentName]/work-summary", () => { it("returns 500 on database error", async () => { mocks.issueFindMany.mockRejectedValue(new Error("connection refused")); - const res = await GET(new Request(`http://localhost/api/agents/${TEST_AGENT}/work-summary`), { - params: Promise.resolve({ agentName: TEST_AGENT }), - }); + const res = await makeRequest( + `http://localhost/api/agents/${TEST_AGENT}/work-summary`, + ); expect(res.status).toBe(500); const body = await res.json(); diff --git a/src/app/api/agents/[agentName]/work-summary/route.ts b/src/app/api/agents/[agentName]/work-summary/route.ts index 0530298..347569f 100644 --- a/src/app/api/agents/[agentName]/work-summary/route.ts +++ b/src/app/api/agents/[agentName]/work-summary/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; +import { authorizeRequest } from "@/lib/auth"; import { prisma, asPrFixQueueClient } from "@/lib/prisma"; import { listQueuedPrFixItems } from "@/lib/pr-fix-queue"; -import { VALID_LANES } from "@/types"; +import { getConfiguredLanes, getDefaultClaimableLane } from "@/lib/lane-config"; type WorkSummaryLaneCounts = { queued: number; inProgress: number }; type PrFixLaneCounts = { total: number; blocked: number }; @@ -24,6 +25,10 @@ function classifyIssueStatus(labels: string[]): "queued" | "inProgress" { export async function GET(request: Request, { params }: { params: Promise<{ agentName: string }> }) { const { agentName } = await params; + if (!(await authorizeRequest(request)).authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { const issues = await prisma.issue.findMany({ where: { @@ -36,13 +41,15 @@ export async function GET(request: Request, { params }: { params: Promise<{ agen }, }); + const configuredLanes = getConfiguredLanes(); const laneCounts: Record = {}; - for (const lane of VALID_LANES) { - laneCounts[lane] = { queued: 0, inProgress: 0 }; + for (const lane of configuredLanes) { + laneCounts[lane.id] = { queued: 0, inProgress: 0 }; } for (const issue of issues) { - const lane = (issue.currentLane ?? "normal").toLowerCase(); + const defaultLane = getDefaultClaimableLane()?.id ?? "normal"; + const lane = (issue.currentLane ?? defaultLane).toLowerCase(); if (!laneCounts[lane]) continue; const status = classifyIssueStatus(issue.labels); diff --git a/src/app/api/issues/groom/route.ts b/src/app/api/issues/groom/route.ts index b81bd51..123e338 100644 --- a/src/app/api/issues/groom/route.ts +++ b/src/app/api/issues/groom/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { removeIssueLabel, addIssueLabel } from "@/lib/github"; import { authorizeRequest } from "@/lib/auth"; +import { getEscalationLane } from "@/lib/lane-config"; /** * Resolve the actor name for grooming attribution. @@ -148,8 +149,8 @@ export async function POST(request: Request) { } case "escalate": { - // Set lane to escalated - groomingData.currentLane = "escalated"; + const escalationLane = getEscalationLane(); + groomingData.currentLane = escalationLane?.id ?? "escalated"; groomingData.nextGroomingAction = "Implement or decompose into actionable sub-tasks"; break; } diff --git a/src/app/api/issues/reconcile/route.ts b/src/app/api/issues/reconcile/route.ts index 22abf20..0c42262 100644 --- a/src/app/api/issues/reconcile/route.ts +++ b/src/app/api/issues/reconcile/route.ts @@ -10,6 +10,7 @@ import { shouldReclassifyStaleBacklog, executeActions, } from "@/lib/issue-reconciliation"; +import { isBacklogLane } from "@/lib/lane-config"; import { computeLinkedPrHealth, toPersistedLinkedPrHealth, type LinkedPrHealth } from "@/lib/linked-pr-health"; import { authorizeRequest } from "@/lib/auth"; import { reconcileStalePrFixItems } from "@/lib/pr-fix-queue"; @@ -216,7 +217,7 @@ export async function POST(request: Request) { data: { currentLane: classification.lane }, }); totalLaneClassified++; - } else if (existingIssue && existingIssue.currentLane === "backlog") { + } else if (existingIssue && existingIssue.currentLane && isBacklogLane(existingIssue.currentLane)) { // Stale-backlog reclassification: the issue has an active status label // but is stuck in the backlog lane. Reclassify to normal or escalated. const reclassify = shouldReclassifyStaleBacklog( diff --git a/src/app/api/issues/route.test.ts b/src/app/api/issues/route.test.ts index e528484..e9d0728 100644 --- a/src/app/api/issues/route.test.ts +++ b/src/app/api/issues/route.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; +const mockToken = "test-agent-token"; +process.env.DISPATCH_AGENT_TOKEN = mockToken; + +vi.mock("@/lib/dispatch-env", () => ({ + isAuthorizedAgentToken: vi.fn((token) => token === mockToken), + isAuthorizedBearerToken: vi.fn((token) => token === mockToken), + getAcceptedAgentTokens: vi.fn(() => [mockToken]), + resetCaches: vi.fn(), +})); + const { mocks } = vi.hoisted(() => ({ mocks: { findManyIssues: vi.fn().mockResolvedValue([]), @@ -13,13 +23,72 @@ vi.mock("@/lib/prisma", () => ({ })); import { GET } from "./route"; +import { resetAuthCaches } from "@/lib/auth"; -function makeRequest(urlString: string) { - return GET(new Request(urlString)); +function makeRequest(urlString: string, includeAuth = true) { + const headers: Record = {}; + if (includeAuth) headers.Authorization = `Bearer ${mockToken}`; + return GET(new Request(urlString, { headers })); } +describe("GET /api/issues — auth", () => { + beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); + vi.clearAllMocks(); + delete process.env.DISPATCH_DONE_RETENTION_DAYS; + mocks.findManyIssues.mockResolvedValue([]); + }); + + it("returns 401 when no auth header is present", async () => { + const res = await makeRequest("http://localhost/api/issues", false); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 for bad bearer token", async () => { + const headers: Record = { Authorization: "Bearer wrong-token" }; + const res = await GET(new Request("http://localhost/api/issues", { headers })); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("authorized request preserves existing issue listing behavior", async () => { + mocks.findManyIssues.mockResolvedValue([ + { id: "1", title: "Test", state: "open" }, + ]); + + const res = await makeRequest("http://localhost/api/issues"); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(1); + expect(body[0].id).toBe("1"); + }); + + it("unauthorized request does not call prisma.issue.findMany", async () => { + await makeRequest("http://localhost/api/issues", false); + + expect(mocks.findManyIssues).not.toHaveBeenCalled(); + }); + + it("authorized invalid lane still returns 400", async () => { + const res = await makeRequest("http://localhost/api/issues?lane=unknown-lane"); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('Invalid lane: "unknown-lane"'); + }); +}); + describe("GET /api/issues — visible issue filtering", () => { beforeEach(() => { + delete process.env.DISPATCH_AUTH_MODE; + resetAuthCaches(); vi.clearAllMocks(); delete process.env.DISPATCH_DONE_RETENTION_DAYS; mocks.findManyIssues.mockResolvedValue([]); @@ -177,4 +246,26 @@ describe("GET /api/issues — visible issue filtering", () => { expect(res.status).toBe(500); expect(body.error).toBe("Failed to fetch issues"); }); + + it("returns 400 for invalid lane filter", async () => { + const res = await makeRequest("http://localhost/api/issues?lane=unknown-lane"); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toContain('Invalid lane: "unknown-lane"'); + }); + + it("filters by valid configured lane", async () => { + await makeRequest("http://localhost/api/issues?lane=normal"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.currentLane).toBe("normal"); + }); + + it("filters by backlog lane", async () => { + await makeRequest("http://localhost/api/issues?lane=backlog"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.currentLane).toBe("backlog"); + }); }); diff --git a/src/app/api/issues/route.ts b/src/app/api/issues/route.ts index 70c2b99..e335882 100644 --- a/src/app/api/issues/route.ts +++ b/src/app/api/issues/route.ts @@ -1,15 +1,22 @@ import { NextResponse } from "next/server"; +import { authorizeRequest } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { buildLabelWhere, buildVisibleIssueWhere, toProjectLabel, buildExcludedLabelWhere, buildNoStatusWhere } from "@/lib/issue-filters"; import { parseExcludedLabels } from "@/lib/config"; +import { isValidLane, getLaneIds } from "@/lib/lane-config"; export async function GET(request: Request) { + if (!(await authorizeRequest(request)).authorized) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { searchParams } = new URL(request.url); const repo = searchParams.get("repo"); const agent = searchParams.get("agent"); const owner = searchParams.get("owner"); const project = searchParams.get("project"); const priority = searchParams.get("priority"); + const lane = searchParams.get("lane"); const decomposed = searchParams.get("decomposed"); const untriaged = searchParams.get("untriaged"); const includeClosed = searchParams.get("includeClosed"); @@ -42,6 +49,17 @@ export async function GET(request: Request) { const noStatusFilter = buildNoStatusWhere(untriaged === "true"); if (noStatusFilter) where.labels = { ...(where.labels as object), ...noStatusFilter }; + // Filter by execution lane + if (lane) { + if (!isValidLane(lane)) { + return NextResponse.json( + { error: `Invalid lane: "${lane}". Must be one of: ${getLaneIds().join(", ")}` }, + { status: 400 }, + ); + } + where.currentLane = lane.toLowerCase(); + } + const issues = await prisma.issue.findMany({ where, include: { repository: true }, diff --git a/src/app/board/kanban-board-client.tsx b/src/app/board/kanban-board-client.tsx index 1961da5..6a116f3 100644 --- a/src/app/board/kanban-board-client.tsx +++ b/src/app/board/kanban-board-client.tsx @@ -3,10 +3,18 @@ import { KanbanBoard } from "@/components/kanban-board"; import { Issue } from "@/types"; +interface LaneOption { + id: string; + title: string; + claimable: boolean; + color?: string; +} + interface KanbanBoardClientProps { initialIssues: Issue[]; + lanes?: LaneOption[]; } -export function KanbanBoardClient({ initialIssues }: KanbanBoardClientProps) { - return ; +export function KanbanBoardClient({ initialIssues, lanes }: KanbanBoardClientProps) { + return ; } diff --git a/src/app/board/page.tsx b/src/app/board/page.tsx index 5d43ecf..c96b79f 100644 --- a/src/app/board/page.tsx +++ b/src/app/board/page.tsx @@ -5,10 +5,11 @@ import { SyncIssuesButton } from "@/components/sync-issues-button"; import { Card, CardContent } from "@/components/ui/card"; import { getTrackedRepos } from "@/lib/config"; import { buildLabelWhere, buildVisibleIssueWhere, discoverLabelFilterOptions, getDoneRetentionDays } from "@/lib/issue-filters"; +import { getConfiguredLanes, isValidLane } from "@/lib/lane-config"; export const dynamic = "force-dynamic"; -async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string, includeClosed?: boolean) { +async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string, lane?: string, includeClosed?: boolean) { const where: Record = { repository: { enabled: true } }; buildVisibleIssueWhere(where, { includeClosed }); @@ -18,6 +19,10 @@ async function getIssues(repo?: string, agent?: string, owner?: string, priority const labels = buildLabelWhere([agent, owner, priority]); if (labels) where.labels = labels; + if (lane && isValidLane(lane)) { + where.currentLane = lane.toLowerCase(); + } + return prisma.issue.findMany({ where, include: { repository: true }, @@ -59,18 +64,19 @@ async function getIssueSyncStatus() { } interface PageProps { - searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string; includeClosed?: string }>; + searchParams: Promise<{ repo?: string; agent?: string; owner?: string; priority?: string; lane?: string; includeClosed?: string }>; } export default async function BoardPage({ searchParams }: PageProps) { const params = await searchParams; const includeClosed = params.includeClosed === "true"; const [issues, repos, filterOptions, syncStatus] = await Promise.all([ - getIssues(params.repo, params.agent, params.owner, params.priority, includeClosed), + getIssues(params.repo, params.agent, params.owner, params.priority, params.lane, includeClosed), getRepos(), getFilterOptions(), getIssueSyncStatus(), ]); + const lanes = getConfiguredLanes(); return (
@@ -92,11 +98,13 @@ export default async function BoardPage({ searchParams }: PageProps) { repos={repos} agents={filterOptions.agents} owners={filterOptions.owners} + lanes={lanes} activeFilters={{ repo: params.repo || "", agent: params.agent || "", owner: params.owner || "", priority: params.priority || "", + lane: params.lane || "", }} /> @@ -121,7 +129,7 @@ export default async function BoardPage({ searchParams }: PageProps) { ) : ( - + )}
); diff --git a/src/components/filter-bar.test.tsx b/src/components/filter-bar.test.tsx index fbb20a8..eaa42c9 100644 --- a/src/components/filter-bar.test.tsx +++ b/src/components/filter-bar.test.tsx @@ -13,6 +13,7 @@ const activeFilters = { agent: "", owner: "", priority: "", + lane: "", }; describe("FilterBar", () => { @@ -46,4 +47,61 @@ describe("FilterBar", () => { expect(await screen.findByRole("option", { name: "alpha" })).toBeInTheDocument(); expect(await screen.findByRole("option", { name: "alice" })).toBeInTheDocument(); }); + + it("renders lane filter options from configured lanes", async () => { + const lanes = [ + { id: "normal", title: "Normal", claimable: true }, + { id: "escalated", title: "Escalated", claimable: true }, + { id: "backlog", title: "Backlog", claimable: false }, + ]; + render( + React.createElement(FilterBar, { + repos: [], + agents: [], + owners: [], + lanes, + activeFilters, + }) + ); + + expect(await screen.findByLabelText("Filter by execution lane")).toBeInTheDocument(); + expect(await screen.findByRole("option", { name: "All Lanes" })).toBeInTheDocument(); + expect(await screen.findByRole("option", { name: "Normal" })).toBeInTheDocument(); + expect(await screen.findByRole("option", { name: "Escalated" })).toBeInTheDocument(); + expect(await screen.findByRole("option", { name: "Backlog (non-claimable)" })).toBeInTheDocument(); + }); + + it("does not render lane filter when lanes prop is omitted", async () => { + render( + React.createElement(FilterBar, { + repos: [], + agents: [], + owners: [], + activeFilters, + }) + ); + + expect(screen.queryByLabelText("Filter by execution lane")).not.toBeInTheDocument(); + }); + + it("renders custom lane titles from config", async () => { + const lanes = [ + { id: "fast", title: "Fast Lane", claimable: true }, + { id: "slow", title: "Slow Lane", claimable: true }, + { id: "parked", title: "Parked", claimable: false }, + ]; + render( + React.createElement(FilterBar, { + repos: [], + agents: [], + owners: [], + lanes, + activeFilters, + }) + ); + + expect(await screen.findByRole("option", { name: "Fast Lane" })).toBeInTheDocument(); + expect(await screen.findByRole("option", { name: "Slow Lane" })).toBeInTheDocument(); + expect(await screen.findByRole("option", { name: "Parked (non-claimable)" })).toBeInTheDocument(); + }); }); diff --git a/src/components/filter-bar.tsx b/src/components/filter-bar.tsx index 3ab7ce2..459b754 100644 --- a/src/components/filter-bar.tsx +++ b/src/components/filter-bar.tsx @@ -4,19 +4,27 @@ import { useRouter, useSearchParams } from "next/navigation"; import { PRIORITY_LABELS, AGENT_PREFIX, OWNER_PREFIX } from "@/types"; import { LABEL_FILTER_HELP } from "@/lib/issue-filters"; +interface LaneOption { + id: string; + title: string; + claimable: boolean; +} + interface FilterBarProps { repos: { fullName: string }[]; agents: string[]; owners: string[]; + lanes?: LaneOption[]; activeFilters: { repo: string; agent: string; owner: string; priority: string; + lane?: string; }; } -export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarProps) { +export function FilterBar({ repos, agents, owners, lanes, activeFilters }: FilterBarProps) { const router = useRouter(); const searchParams = useSearchParams(); @@ -97,6 +105,22 @@ export function FilterBar({ repos, agents, owners, activeFilters }: FilterBarPro ))} + + {lanes && lanes.length > 0 && ( + + )} ); } diff --git a/src/components/issue-card.test.tsx b/src/components/issue-card.test.tsx new file mode 100644 index 0000000..7c81730 --- /dev/null +++ b/src/components/issue-card.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { IssueCard } from "./issue-card"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@dnd-kit/sortable", () => ({ + useSortable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: null, + isDragging: false, + }), +})); + +vi.mock("@dnd-kit/utilities", () => ({ + CSS: { Transform: { toString: () => "" } }, +})); + +vi.mock("@/lib/client-auth", () => ({ + authedFetch: vi.fn(), +})); + +const makeIssue = (overrides = {}) => ({ + id: "test-issue-1", + number: 42, + title: "Test issue", + body: null, + state: "open", + url: "https://github.com/test/repo/issues/42", + labels: ["status/ready"], + assignees: [], + commentsCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + closedAt: null, + repository: { fullName: "test/repo" }, + ...overrides, +}); + +describe("IssueCard lane badge", () => { + it("renders lane badge with configured title and color (hex with #)", () => { + const lanes = [ + { id: "normal", title: "Normal", claimable: true, color: "#3b82f6" }, + { id: "escalated", title: "Escalated", claimable: true, color: "#f97316" }, + ]; + const issue = makeIssue({ currentLane: "normal" }); + + render(React.createElement(IssueCard, { issue, lanes })); + + expect(screen.getByText("Normal")).toBeInTheDocument(); + const badge = screen.getByText("Normal").closest("span"); + expect(badge).toHaveAttribute("title", "Lane: Normal"); + }); + + it("renders lane badge with color that lacks # prefix", () => { + const lanes = [ + { id: "normal", title: "Normal", claimable: true, color: "3b82f6" }, + ]; + const issue = makeIssue({ currentLane: "normal" }); + + render(React.createElement(IssueCard, { issue, lanes })); + + expect(screen.getByText("Normal")).toBeInTheDocument(); + const badge = screen.getByText("Normal").closest("span"); + // jsdom normalizes hex colors to rgb/rgba; verify a color was applied + expect(badge?.style.backgroundColor).toMatch(/rgba?\(/); + expect(badge?.style.color).toMatch(/rgba?\(/); + }); + + it("renders non-claimable lane with reduced opacity", () => { + const lanes = [ + { id: "backlog", title: "Backlog", claimable: false, color: "#6b7280" }, + ]; + const issue = makeIssue({ currentLane: "backlog" }); + + render(React.createElement(IssueCard, { issue, lanes })); + + expect(screen.getByText("Backlog")).toBeInTheDocument(); + const badge = screen.getByText("Backlog").closest("span"); + expect(badge).toHaveAttribute("title", "Lane: Backlog (non-claimable)"); + expect(badge?.style.opacity).toBe("0.6"); + }); + + it("does not render lane badge when lanes prop is omitted", () => { + const issue = makeIssue({ currentLane: "normal" }); + + render(React.createElement(IssueCard, { issue })); + + expect(screen.queryByText("Normal")).not.toBeInTheDocument(); + }); + + it("does not render lane badge when currentLane is null", () => { + const lanes = [ + { id: "normal", title: "Normal", claimable: true, color: "#3b82f6" }, + ]; + const issue = makeIssue({ currentLane: null }); + + render(React.createElement(IssueCard, { issue, lanes })); + + expect(screen.queryByText("Normal")).not.toBeInTheDocument(); + }); + + it("does not render lane badge when lane id is not in config", () => { + const lanes = [ + { id: "normal", title: "Normal", claimable: true, color: "#3b82f6" }, + ]; + const issue = makeIssue({ currentLane: "unknown-lane" }); + + render(React.createElement(IssueCard, { issue, lanes })); + + expect(screen.queryByText("Normal")).not.toBeInTheDocument(); + }); + + it("uses default gray color when lane has no color", () => { + const lanes = [ + { id: "custom", title: "Custom", claimable: true }, + ]; + const issue = makeIssue({ currentLane: "custom" }); + + render(React.createElement(IssueCard, { issue, lanes })); + + expect(screen.getByText("Custom")).toBeInTheDocument(); + const badge = screen.getByText("Custom").closest("span"); + // jsdom normalizes hex colors to rgb/rgba; verify a color was applied + expect(badge?.style.backgroundColor).toMatch(/rgba?\(/); + expect(badge?.style.color).toMatch(/rgba?\(/); + }); +}); diff --git a/src/components/issue-card.tsx b/src/components/issue-card.tsx index 58eb2a9..095e81d 100644 --- a/src/components/issue-card.tsx +++ b/src/components/issue-card.tsx @@ -5,12 +5,20 @@ import { CSS } from "@dnd-kit/utilities"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { cn } from "@/lib/utils"; import { Issue, LABEL_COLORS, AGENT_PREFIX, OWNER_PREFIX, GROOM_ACTION_LABELS, GroomAction, isValidGroomAction } from "@/types"; + +interface LaneOption { + id: string; + title: string; + claimable: boolean; + color?: string; +} import { GitPullRequest, MessageSquare, ExternalLink, MoreVertical, User, Users, X, Scissors, AlertTriangle, Info, Ban } from "lucide-react"; import { useState, useCallback } from "react"; import { authedFetch } from "@/lib/client-auth"; interface IssueCardProps { issue: Issue; + lanes?: LaneOption[]; isDragging?: boolean; onIssueUpdate?: (updatedIssue: Issue) => void; } @@ -29,7 +37,12 @@ function formatFollowupReason(reason: string): string { } } -export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) { +/** Normalize a hex color string to always include a leading `#`. */ +function normalizeHexColor(color: string): string { + return color.startsWith("#") ? color : `#${color}`; +} + +export function IssueCard({ issue, lanes, isDragging, onIssueUpdate }: IssueCardProps) { const { attributes, listeners, @@ -545,6 +558,25 @@ export function IssueCard({ issue, isDragging, onIssueUpdate }: IssueCardProps) {priorityLabel.replace("priority/", "p")} )} + {issue.currentLane && lanes && (() => { + const laneConfig = lanes.find((l) => l.id === issue.currentLane); + if (!laneConfig) return null; + const rawColor = laneConfig.color ?? "#6b7280"; + const hex = normalizeHexColor(rawColor).slice(1); + return ( + + {laneConfig.title} + + ); + })()} {issue.linkedPrNeedsFollowup && ( (function KanbanBoard( - { initialIssues }, + { initialIssues, lanes }, ref ) { const [issues, setIssues] = useState(initialIssues); @@ -319,6 +327,7 @@ export const KanbanBoard = forwardRef(function doRefresh()} /> ))}