From 3bb33af74f018e8d0b4dddc1f3184cd6998699ba Mon Sep 17 00:00:00 2001 From: Saffron Date: Tue, 16 Jun 2026 20:16:30 -0600 Subject: [PATCH] feat(api): add untriaged filter to /api/issues for grooming intake Add ?untriaged=true query parameter to GET /api/issues to surface open issues with no status/* label. This provides a grooming intake path via the main issues API, complementing the existing dedicated /api/issues/untriaged endpoint. - Add buildNoStatusWhere() helper in issue-filters.ts - Wire untriaged param into GET /api/issues route - Add tests for buildNoStatusWhere and combined filter behavior --- src/app/api/issues/route.test.ts | 32 ++++++++++++++++++++++++++++++++ src/app/api/issues/route.ts | 7 ++++++- src/lib/issue-filters.test.ts | 18 +++++++++++++++++- src/lib/issue-filters.ts | 11 ++++++++++- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/app/api/issues/route.test.ts b/src/app/api/issues/route.test.ts index b01add7..e528484 100644 --- a/src/app/api/issues/route.test.ts +++ b/src/app/api/issues/route.test.ts @@ -99,6 +99,38 @@ describe("GET /api/issues — visible issue filtering", () => { expect(call.where.decomposed).toBe(true); }); + it("filters for untriaged issues (no status label) when untriaged=true", async () => { + await makeRequest("http://localhost/api/issues?untriaged=true"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toBeDefined(); + expect(call.where.labels.hasNone).toBeDefined(); + expect(call.where.labels.hasNone).toContain("status/backlog"); + expect(call.where.labels.hasNone).toContain("status/ready"); + expect(call.where.labels.hasNone).toContain("status/in-progress"); + expect(call.where.labels.hasNone).toContain("status/in-review"); + expect(call.where.labels.hasNone).toContain("status/done"); + }); + + it("does not add hasNone filter when untriaged is not true", async () => { + await makeRequest("http://localhost/api/issues?untriaged=false"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + // labels may have other filters but should not have hasNone from noStatus + if (call.where.labels) { + expect(call.where.labels.hasNone).toBeUndefined(); + } + }); + + it("combines untriaged filter with agent filter", async () => { + await makeRequest("http://localhost/api/issues?untriaged=true&agent=agent/alpha"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels.has).toBe("agent/alpha"); + expect(call.where.labels.hasNone).toBeDefined(); + expect(call.where.labels.hasNone).toContain("status/ready"); + }); + it("orders by updatedAt descending", async () => { await makeRequest("http://localhost/api/issues"); diff --git a/src/app/api/issues/route.ts b/src/app/api/issues/route.ts index 4cbf6bd..70c2b99 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, buildVisibleIssueWhere, toProjectLabel, buildExcludedLabelWhere } from "@/lib/issue-filters"; +import { buildLabelWhere, buildVisibleIssueWhere, toProjectLabel, buildExcludedLabelWhere, buildNoStatusWhere } from "@/lib/issue-filters"; import { parseExcludedLabels } from "@/lib/config"; export async function GET(request: Request) { @@ -11,6 +11,7 @@ export async function GET(request: Request) { const project = searchParams.get("project"); const priority = searchParams.get("priority"); const decomposed = searchParams.get("decomposed"); + const untriaged = searchParams.get("untriaged"); const includeClosed = searchParams.get("includeClosed"); try { @@ -37,6 +38,10 @@ export async function GET(request: Request) { where.labels = { ...(where.labels as object), ...excludedLabelFilter }; } + // Filter for untriaged issues (no status/* label) — grooming intake + const noStatusFilter = buildNoStatusWhere(untriaged === "true"); + if (noStatusFilter) where.labels = { ...(where.labels as object), ...noStatusFilter }; + const issues = await prisma.issue.findMany({ where, include: { repository: true }, diff --git a/src/lib/issue-filters.test.ts b/src/lib/issue-filters.test.ts index 1c65255..833cabb 100644 --- a/src/lib/issue-filters.test.ts +++ b/src/lib/issue-filters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { buildLabelWhere, discoverLabelFilterOptions, toProjectLabel, buildVisibleIssueWhere, getDoneRetentionDays, DEFAULT_DONE_RETENTION_DAYS } from "./issue-filters"; +import { buildLabelWhere, discoverLabelFilterOptions, toProjectLabel, buildVisibleIssueWhere, getDoneRetentionDays, DEFAULT_DONE_RETENTION_DAYS, buildNoStatusWhere } from "./issue-filters"; describe("issue filter helpers", () => { it("discovers sorted agent and owner options from labels only", () => { @@ -42,6 +42,22 @@ describe("issue filter helpers", () => { }); }); +describe("buildNoStatusWhere", () => { + it("returns undefined when includeUntriaged is false", () => { + expect(buildNoStatusWhere(false)).toBeUndefined(); + }); + + it("returns hasNone filter with STATUS_LABELS when includeUntriaged is true", () => { + const result = buildNoStatusWhere(true); + expect(result).toBeDefined(); + expect(result!.hasNone).toContain("status/backlog"); + expect(result!.hasNone).toContain("status/ready"); + expect(result!.hasNone).toContain("status/in-progress"); + expect(result!.hasNone).toContain("status/in-review"); + expect(result!.hasNone).toContain("status/done"); + }); +}); + describe("buildVisibleIssueWhere", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/lib/issue-filters.ts b/src/lib/issue-filters.ts index 06e6fb5..b600a21 100644 --- a/src/lib/issue-filters.ts +++ b/src/lib/issue-filters.ts @@ -1,4 +1,4 @@ -import { AGENT_PREFIX, isAgentLabel, isOwnerLabel, OWNER_PREFIX } from "@/types"; +import { AGENT_PREFIX, isAgentLabel, isOwnerLabel, OWNER_PREFIX, STATUS_LABELS } from "@/types"; export interface VisibleIssueWhereOptions { includeClosed?: boolean; @@ -49,6 +49,15 @@ export function buildExcludedLabelWhere(excludedLabels: string[]) { return { hasNone: excludedLabels }; } +/** + * Build a Prisma where clause that matches issues with no status/* label. + * Used for grooming intake — surfaces untriaged open issues. + */ +export function buildNoStatusWhere(includeUntriaged: boolean): { hasNone: string[] } | undefined { + if (!includeUntriaged) return undefined; + return { hasNone: STATUS_LABELS }; +} + export function toProjectLabel(project: string | null | undefined) { return project ? `project/${project}` : undefined; }