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
32 changes: 32 additions & 0 deletions src/app/api/issues/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
7 changes: 6 additions & 1 deletion 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, 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) {
Expand All @@ -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 {
Expand All @@ -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 },
Expand Down
18 changes: 17 additions & 1 deletion src/lib/issue-filters.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 10 additions & 1 deletion src/lib/issue-filters.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down