diff --git a/.env.example b/.env.example index 87a3c3c..0098e9e 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,9 @@ DISPATCH_AGENT_TOKEN="your_agent_token_here" # Closed issue cache retention (days) — closed issues older than this are pruned via POST /api/issues/prune-closed # DISPATCH_CLOSED_ISSUE_RETENTION_DAYS=30 +# Done issue visibility retention (days) — recently closed Done issues appear in Board/Projects/API for this window; old Done issues are hidden by default (default: 7) +# DISPATCH_DONE_RETENTION_DAYS=7 + # NextAuth.js secret — required for OIDC mode (JWT signing). # Generate a secure random string: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # NEXTAUTH_SECRET="your_nextauth_secret_here" diff --git a/src/app/api/issues/route.test.ts b/src/app/api/issues/route.test.ts new file mode 100644 index 0000000..b01add7 --- /dev/null +++ b/src/app/api/issues/route.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + findManyIssues: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + issue: { findMany: mocks.findManyIssues }, + }, +})); + +import { GET } from "./route"; + +function makeRequest(urlString: string) { + return GET(new Request(urlString)); +} + +describe("GET /api/issues — visible issue filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.DISPATCH_DONE_RETENTION_DAYS; + mocks.findManyIssues.mockResolvedValue([]); + }); + + it("defaults to open issues + recently closed Done issues (7-day retention)", async () => { + await makeRequest("http://localhost/api/issues"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.OR).toBeDefined(); + expect(Array.isArray(call.where.OR)).toBe(true); + expect(call.where.OR.length).toBe(2); + expect(call.where.OR[0]).toEqual({ state: "open" }); + expect(call.where.OR[1].state).toBe("closed"); + expect(call.where.OR[1].labels.has).toBe("status/done"); + expect(call.where.OR[1].closedAt.gte).toBeDefined(); + }); + + it("includes all issues when includeClosed=true", async () => { + await makeRequest("http://localhost/api/issues?includeClosed=true"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.OR).toBeUndefined(); + expect(call.where.state).toBeUndefined(); + }); + + it("respects DISPATCH_DONE_RETENTION_DAYS environment variable", async () => { + process.env.DISPATCH_DONE_RETENTION_DAYS = "30"; + await makeRequest("http://localhost/api/issues"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.OR).toBeDefined(); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 30); + expect(call.where.OR[1].closedAt.gte).toBeTruthy(); + }); + + it("filters by repository", async () => { + await makeRequest("http://localhost/api/issues?repo=myorg/myrepo"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.repository).toEqual({ enabled: true, fullName: "myorg/myrepo" }); + }); + + it("filters by agent label", async () => { + await makeRequest("http://localhost/api/issues?agent=agent/alpha"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ has: "agent/alpha" }); + }); + + it("filters by owner label", async () => { + await makeRequest("http://localhost/api/issues?owner=owner/alice"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ has: "owner/alice" }); + }); + + it("filters by priority label", async () => { + await makeRequest("http://localhost/api/issues?priority=priority/p1"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ has: "priority/p1" }); + }); + + it("filters by project label", async () => { + await makeRequest("http://localhost/api/issues?project=api"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ has: "project/api" }); + }); + + it("filters by decomposed status", async () => { + await makeRequest("http://localhost/api/issues?decomposed=true"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.decomposed).toBe(true); + }); + + it("orders by updatedAt descending", async () => { + await makeRequest("http://localhost/api/issues"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.orderBy).toEqual({ updatedAt: "desc" }); + }); + + it("includes repository relation", async () => { + await makeRequest("http://localhost/api/issues"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.include).toEqual({ repository: true }); + }); + + it("combines agent, owner, and priority label filters", async () => { + await makeRequest( + "http://localhost/api/issues?agent=agent/alpha&owner=owner/alice&priority=priority/p1" + ); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ hasEvery: ["agent/alpha", "owner/alice", "priority/p1"] }); + }); + + it("returns JSON array of issues", async () => { + const expectedIssues = [ + { id: "1", title: "Test", state: "open" }, + { id: "2", title: "Another", state: "closed" }, + ]; + mocks.findManyIssues.mockResolvedValueOnce(expectedIssues); + + const res = await makeRequest("http://localhost/api/issues"); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual(expectedIssues); + }); + + it("returns 500 on database error", async () => { + mocks.findManyIssues.mockRejectedValueOnce(new Error("db connection failed")); + + const res = await makeRequest("http://localhost/api/issues"); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.error).toBe("Failed to fetch issues"); + }); +}); diff --git a/src/app/api/issues/route.ts b/src/app/api/issues/route.ts index f8a69f1..059c721 100644 --- a/src/app/api/issues/route.ts +++ b/src/app/api/issues/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { buildLabelWhere, toProjectLabel } from "@/lib/issue-filters"; +import { buildLabelWhere, buildVisibleIssueWhere, toProjectLabel } from "@/lib/issue-filters"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -15,10 +15,7 @@ export async function GET(request: Request) { try { const where: Record = { repository: { enabled: true } }; - // Default to open issues only; include closed when explicitly requested - if (includeClosed !== "true") { - where.state = "open"; - } + buildVisibleIssueWhere(where, { includeClosed: includeClosed === "true" }); if (repo) { where.repository = { ...(where.repository as object), fullName: repo }; diff --git a/src/app/board/page.tsx b/src/app/board/page.tsx index affe055..5d43ecf 100644 --- a/src/app/board/page.tsx +++ b/src/app/board/page.tsx @@ -4,35 +4,14 @@ import { FilterBar } from "@/components/filter-bar"; import { SyncIssuesButton } from "@/components/sync-issues-button"; import { Card, CardContent } from "@/components/ui/card"; import { getTrackedRepos } from "@/lib/config"; -import { buildLabelWhere, discoverLabelFilterOptions } from "@/lib/issue-filters"; +import { buildLabelWhere, buildVisibleIssueWhere, discoverLabelFilterOptions, getDoneRetentionDays } from "@/lib/issue-filters"; export const dynamic = "force-dynamic"; -const DONE_RETENTION_DAYS = parseInt(process.env.DISPATCH_DONE_RETENTION_DAYS ?? "7", 10) || 7; - -function getDoneRetentionCutoff(): Date { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - DONE_RETENTION_DAYS); - return cutoff; -} - async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string, includeClosed?: boolean) { const where: Record = { repository: { enabled: true } }; - if (includeClosed === true) { - // Show all issues regardless of state or age - // No state filter — let Kanban grouping sort them by status label - } else { - // Default: open issues + recently closed Done issues (within retention window) - where.OR = [ - { state: "open" }, - { - state: "closed", - labels: { has: "status/done" }, - closedAt: { gte: getDoneRetentionCutoff() }, - }, - ]; - } + buildVisibleIssueWhere(where, { includeClosed }); if (repo) where.repository = { ...(where.repository as object), fullName: repo }; @@ -132,7 +111,7 @@ export default async function BoardPage({ searchParams }: PageProps) { {syncStatus.cachedIssueCount > 0 ? "Clear or adjust the filters to see synced issues." : syncStatus.trackedRepoCount > 0 - ? "Click Sync Issues to import open GitHub issues from tracked repositories. Recently completed Done issues are shown for " + DONE_RETENTION_DAYS + " days." + ? `Click Sync Issues to import open GitHub issues from tracked repositories. Recently completed Done issues are shown for ${getDoneRetentionDays()} days.` : "No tracked repositories are configured yet. Add tracked repositories before syncing issues."}

diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 7f2a070..80552ad 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { BOARD_COLUMNS, STATUS_LABELS } from "@/types"; +import { buildVisibleIssueWhere } from "@/lib/issue-filters"; import { getProjectIssueStatus, groupIssuesByProject } from "@/lib/projects"; export const dynamic = "force-dynamic"; @@ -10,30 +11,14 @@ interface PageProps { searchParams: Promise>; } -const DONE_RETENTION_DAYS = parseInt(process.env.DISPATCH_DONE_RETENTION_DAYS ?? "7", 10) || 7; - -function getDoneRetentionCutoff(): Date { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - DONE_RETENTION_DAYS); - return cutoff; -} - async function getProjects() { // Fetch open issues + recently closed Done issues (within retention window) // to match Board page retention semantics. - const doneCutoff = getDoneRetentionCutoff(); + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where); + const issues = await prisma.issue.findMany({ - where: { - repository: { enabled: true }, - OR: [ - { state: "open" }, - { - state: "closed", - labels: { has: "status/done" }, - closedAt: { gte: doneCutoff }, - }, - ], - }, + where, include: { repository: true }, }); diff --git a/src/lib/issue-filters.test.ts b/src/lib/issue-filters.test.ts index 4272855..1c65255 100644 --- a/src/lib/issue-filters.test.ts +++ b/src/lib/issue-filters.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { buildLabelWhere, discoverLabelFilterOptions, toProjectLabel } from "./issue-filters"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { buildLabelWhere, discoverLabelFilterOptions, toProjectLabel, buildVisibleIssueWhere, getDoneRetentionDays, DEFAULT_DONE_RETENTION_DAYS } from "./issue-filters"; describe("issue filter helpers", () => { it("discovers sorted agent and owner options from labels only", () => { @@ -41,3 +41,114 @@ describe("issue filter helpers", () => { expect(toProjectLabel("")).toBeUndefined(); }); }); + +describe("buildVisibleIssueWhere", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.DISPATCH_DONE_RETENTION_DAYS; + }); + + it("defaults to open issues + recently closed Done issues (7-day retention)", () => { + const now = new Date(); + const expectedCutoff = new Date(); + expectedCutoff.setDate(expectedCutoff.getDate() - 7); + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where); + + const or = where.OR as Array>; + expect(or).toBeDefined(); + expect(Array.isArray(or)).toBe(true); + expect(or.length).toBe(2); + expect(or[0]).toEqual({ state: "open" }); + const branch1 = or[1] as Record; + expect(branch1.state).toBe("closed"); + expect((branch1.labels as Record).has).toBe("status/done"); + const gte = (branch1.closedAt as Record).gte as Date; + expect(gte).toBeInstanceOf(Date); + expect(gte.getTime()).toBeGreaterThanOrEqual(expectedCutoff.getTime() - 1000); + expect(gte.getTime()).toBeLessThanOrEqual(now.getTime()); + }); + + it("shows all issues when includeClosed is true", () => { + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where, { includeClosed: true }); + + expect(where.OR).toBeUndefined(); + expect(where.state).toBeUndefined(); + }); + + it("respects custom doneRetentionDays option with exact cutoff", () => { + const now = new Date(); + const expectedCutoff = new Date(); + expectedCutoff.setDate(expectedCutoff.getDate() - 30); + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where, { includeClosed: false, doneRetentionDays: 30 }); + + const or = where.OR as Array>; + expect(or).toBeDefined(); + const branch1 = or[1] as Record; + const gte = (branch1.closedAt as Record).gte as Date; + expect(gte).toBeInstanceOf(Date); + expect(gte.getTime()).toBeGreaterThanOrEqual(expectedCutoff.getTime() - 1000); + expect(gte.getTime()).toBeLessThanOrEqual(now.getTime()); + }); + + it("respects DISPATCH_DONE_RETENTION_DAYS env var with exact cutoff", () => { + const now = new Date(); + const expectedCutoff = new Date(); + expectedCutoff.setDate(expectedCutoff.getDate() - 14); + process.env.DISPATCH_DONE_RETENTION_DAYS = "14"; + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where); + + const or = where.OR as Array>; + const branch1 = or[1] as Record; + const gte = (branch1.closedAt as Record).gte as Date; + expect(gte).toBeInstanceOf(Date); + expect(gte.getTime()).toBeGreaterThanOrEqual(expectedCutoff.getTime() - 1000); + expect(gte.getTime()).toBeLessThanOrEqual(now.getTime()); + delete process.env.DISPATCH_DONE_RETENTION_DAYS; + }); + + it("does not set OR when includeClosed is true", () => { + const where: Record = { repository: { enabled: true }, state: "open" }; + buildVisibleIssueWhere(where, { includeClosed: true }); + + expect(where.OR).toBeUndefined(); + }); + + it("preserves existing where clauses while adding OR", () => { + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where); + + expect(where.repository).toEqual({ enabled: true }); + expect(where.OR).toBeDefined(); + }); +}); + +describe("getDoneRetentionDays", () => { + beforeEach(() => { + delete process.env.DISPATCH_DONE_RETENTION_DAYS; + }); + + it("returns default of 7 when env var is not set", () => { + expect(getDoneRetentionDays()).toBe(DEFAULT_DONE_RETENTION_DAYS); + expect(getDoneRetentionDays()).toBe(7); + }); + + it("respects DISPATCH_DONE_RETENTION_DAYS environment variable", () => { + process.env.DISPATCH_DONE_RETENTION_DAYS = "30"; + expect(getDoneRetentionDays()).toBe(30); + }); + + it("clamps invalid values to default", () => { + process.env.DISPATCH_DONE_RETENTION_DAYS = "abc"; + expect(getDoneRetentionDays()).toBe(DEFAULT_DONE_RETENTION_DAYS); + + process.env.DISPATCH_DONE_RETENTION_DAYS = "0"; + expect(getDoneRetentionDays()).toBe(DEFAULT_DONE_RETENTION_DAYS); + + process.env.DISPATCH_DONE_RETENTION_DAYS = "-5"; + expect(getDoneRetentionDays()).toBe(DEFAULT_DONE_RETENTION_DAYS); + }); +}); diff --git a/src/lib/issue-filters.ts b/src/lib/issue-filters.ts index 6a23ffe..0e2fadb 100644 --- a/src/lib/issue-filters.ts +++ b/src/lib/issue-filters.ts @@ -1,5 +1,10 @@ import { AGENT_PREFIX, isAgentLabel, isOwnerLabel, OWNER_PREFIX } from "@/types"; +export interface VisibleIssueWhereOptions { + includeClosed?: boolean; + doneRetentionDays?: number; +} + export interface LabelFilterOptions { agents: string[]; owners: string[]; @@ -35,6 +40,35 @@ export function toProjectLabel(project: string | null | undefined) { return project ? `project/${project}` : undefined; } +export const DEFAULT_DONE_RETENTION_DAYS = 7; + +export function getDoneRetentionDays(): number { + const parsed = parseInt(process.env.DISPATCH_DONE_RETENTION_DAYS ?? String(DEFAULT_DONE_RETENTION_DAYS), 10); + return parsed > 0 ? parsed : DEFAULT_DONE_RETENTION_DAYS; +} + +export function buildVisibleIssueWhere(where: Record, options?: VisibleIssueWhereOptions): void { + const { includeClosed = false, doneRetentionDays } = options ?? {}; + const retentionDays = doneRetentionDays ?? getDoneRetentionDays(); + + if (includeClosed) { + // Show all issues regardless of state or age — no state filter + return; + } + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + + where.OR = [ + { state: "open" }, + { + state: "closed", + labels: { has: "status/done" }, + closedAt: { gte: cutoff }, + }, + ]; +} + export const LABEL_FILTER_HELP = { agent: `Agent filters use ${AGENT_PREFIX} labels on synced GitHub issues.`, owner: `Owner filters use ${OWNER_PREFIX} labels on synced GitHub issues.`,