Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
148 changes: 148 additions & 0 deletions src/app/api/issues/route.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
7 changes: 2 additions & 5 deletions src/app/api/issues/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -15,10 +15,7 @@ export async function GET(request: Request) {
try {
const where: Record<string, unknown> = { 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 };
Expand Down
27 changes: 3 additions & 24 deletions src/app/board/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { 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 };

Expand Down Expand Up @@ -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."}
</p>
</div>
Expand Down
25 changes: 5 additions & 20 deletions src/app/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -10,30 +11,14 @@ interface PageProps {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}

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<string, unknown> = { 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 },
});

Expand Down
115 changes: 113 additions & 2 deletions src/lib/issue-filters.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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<string, unknown> = { repository: { enabled: true } };
buildVisibleIssueWhere(where);

const or = where.OR as Array<Record<string, unknown>>;
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<string, unknown>;
expect(branch1.state).toBe("closed");
expect((branch1.labels as Record<string, string>).has).toBe("status/done");
const gte = (branch1.closedAt as Record<string, Date>).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<string, unknown> = { 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<string, unknown> = { repository: { enabled: true } };
buildVisibleIssueWhere(where, { includeClosed: false, doneRetentionDays: 30 });

const or = where.OR as Array<Record<string, unknown>>;
expect(or).toBeDefined();
const branch1 = or[1] as Record<string, unknown>;
const gte = (branch1.closedAt as Record<string, Date>).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<string, unknown> = { repository: { enabled: true } };
buildVisibleIssueWhere(where);

const or = where.OR as Array<Record<string, unknown>>;
const branch1 = or[1] as Record<string, unknown>;
const gte = (branch1.closedAt as Record<string, Date>).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<string, unknown> = { repository: { enabled: true }, state: "open" };
buildVisibleIssueWhere(where, { includeClosed: true });

expect(where.OR).toBeUndefined();
});

it("preserves existing where clauses while adding OR", () => {
const where: Record<string, unknown> = { 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);
});
});
Loading
Loading