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.`,