From 39e0fba65f4e8212d07788723f7b4c602badea03 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 26 May 2026 13:01:44 +0000 Subject: [PATCH] Filter issues by plan document --- server/src/__tests__/issues-service.test.ts | 72 +++++++++++++++++++++ server/src/routes/issues.ts | 19 ++++++ server/src/services/issues.ts | 23 +++++++ ui/src/api/issues.test.ts | 8 +++ ui/src/api/issues.ts | 4 ++ 5 files changed, 126 insertions(+) diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 07f5717b..0f4f9e7f 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -149,9 +149,11 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { afterEach(async () => { await db.delete(issueComments); await db.delete(issueRelations); + await db.delete(issueDocuments); await db.delete(issueInboxArchives); await db.delete(activityLog); await db.delete(issues); + await db.delete(documents); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); @@ -386,6 +388,76 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]); }); + it("filters issues by whether they have a plan document", async () => { + const companyId = randomUUID(); + const withPlanId = randomUUID(); + const withoutPlanId = randomUUID(); + const otherDocumentId = randomUUID(); + const planDocumentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: withPlanId, + companyId, + title: "Issue with plan", + status: "todo", + priority: "medium", + }, + { + id: withoutPlanId, + companyId, + title: "Issue without plan", + status: "todo", + priority: "medium", + }, + ]); + + await db.insert(documents).values([ + { + id: planDocumentId, + companyId, + title: null, + format: "markdown", + latestBody: "# Plan", + }, + { + id: otherDocumentId, + companyId, + title: "Notes", + format: "markdown", + latestBody: "# Notes", + }, + ]); + + await db.insert(issueDocuments).values([ + { + companyId, + issueId: withPlanId, + documentId: planDocumentId, + key: "plan", + }, + { + companyId, + issueId: withoutPlanId, + documentId: otherDocumentId, + key: "notes", + }, + ]); + + const withPlan = await svc.list(companyId, { hasPlanDocument: true }); + const withoutPlan = await svc.list(companyId, { hasPlanDocument: false }); + + expect(withPlan.map((issue) => issue.id)).toEqual([withPlanId]); + expect(withoutPlan.map((issue) => issue.id)).toEqual([withoutPlanId]); + }); + it("can page issues by most recently updated before priority", async () => { const companyId = randomUUID(); const oldCriticalIssueId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 85492638..e31505c4 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1193,6 +1193,13 @@ export function issueRoutes( return value === true || value === "true" || value === "1"; } + function parseOptionalBooleanQuery(value: unknown) { + if (value === undefined) return undefined; + if (value === true || value === "true" || value === "1") return true; + if (value === false || value === "false" || value === "0") return false; + return null; + } + function shouldIncludeDocumentAnnotations(req: Request) { if (req.query.includeAnnotations === "false" || req.query.includeAnnotations === "0") return false; return req.actor.type === "agent" || parseBooleanQuery(req.query.includeAnnotations); @@ -1993,6 +2000,7 @@ export function issueRoutes( const attention = req.query.attention as string | undefined; const sortField = req.query.sortField as string | undefined; const sortDir = req.query.sortDir as string | undefined; + const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument); if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -2030,6 +2038,10 @@ export function issueRoutes( res.status(400).json({ error: "sortDir must be 'asc' or 'desc' when provided" }); return; } + if (hasPlanDocument === null) { + res.status(400).json({ error: "hasPlanDocument must be true or false when provided" }); + return; + } const offset = parsedOffset ?? 0; const result = await svc.list(companyId, { @@ -2059,6 +2071,7 @@ export function issueRoutes( includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", includeBlockedInboxAttention: req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1", + hasPlanDocument, q: req.query.q as string | undefined, limit, offset, @@ -2094,6 +2107,7 @@ export function issueRoutes( const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const attention = req.query.attention as string | undefined; + const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument); if (attention !== "blocked") { res.status(400).json({ error: "issues/count currently requires attention=blocked" }); return; @@ -2102,6 +2116,10 @@ export function issueRoutes( res.status(400).json({ error: "issues/count does not accept limit or offset" }); return; } + if (hasPlanDocument === null) { + res.status(400).json({ error: "hasPlanDocument must be true or false when provided" }); + return; + } const count = await svc.count(companyId, { attention: "blocked", @@ -2126,6 +2144,7 @@ export function issueRoutes( req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1", includeBlockedBy: true, includeBlockedInboxAttention: true, + hasPlanDocument, q: req.query.q as string | undefined, }); res.json({ count }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 32472bcf..58af3c88 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -241,6 +241,7 @@ export interface IssueFilters { includePluginOperations?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; + hasPlanDocument?: boolean; q?: string; limit?: number; offset?: number; @@ -2169,6 +2170,19 @@ function issueRef(row: Pick` + EXISTS ( + SELECT 1 + FROM ${issueDocuments} + WHERE ${issueDocuments.companyId} = ${companyId} + AND ${issueDocuments.issueId} = ${issues.id} + AND ${issueDocuments.key} = 'plan' + ) + `; + return hasPlanDocument ? existsPlanDocument : sql`NOT ${existsPlanDocument}`; +} + function isoDate(value: Date | string | null | undefined): string | null { if (!value) return null; const date = value instanceof Date ? value : new Date(value); @@ -2881,6 +2895,9 @@ async function blockedInboxIssueConditions( if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); + if (filters?.hasPlanDocument !== undefined) { + conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument)); + } if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition()); if (filters?.labelId) { const labeledIssueIds = await dbOrTx @@ -3826,6 +3843,9 @@ export function issueService(db: Db) { if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); + if (filters?.hasPlanDocument !== undefined) { + conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument)); + } if (!shouldIncludePluginOperationIssues(filters)) { conditions.push(nonPluginOperationIssueCondition()); } @@ -3989,6 +4009,9 @@ export function issueService(db: Db) { if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); + if (filters?.hasPlanDocument !== undefined) { + conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument)); + } if (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition()); const [row] = await db .select({ count: sql`count(*)` }) diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 56bdcf47..91f736f6 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -63,6 +63,14 @@ describe("issuesApi.list", () => { ); }); + it("passes plan document filters through to the company issues endpoint", async () => { + await issuesApi.list("company-1", { hasPlanDocument: false, limit: 25 }); + + expect(mockApi.get).toHaveBeenCalledWith( + "/companies/company-1/issues?hasPlanDocument=false&limit=25", + ); + }); + it("posts recovery action resolution to the source issue endpoint", async () => { await issuesApi.resolveRecoveryAction("issue-1", { actionId: "00000000-0000-0000-0000-0000000000aa", diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 55925ed5..ea2bb6c7 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -58,6 +58,7 @@ export const issuesApi = { includeRoutineExecutions?: boolean; includeBlockedBy?: boolean; includeBlockedInboxAttention?: boolean; + hasPlanDocument?: boolean; q?: string; limit?: number; offset?: number; @@ -86,6 +87,9 @@ export const issuesApi = { if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true"); if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true"); + if (filters?.hasPlanDocument !== undefined) { + params.set("hasPlanDocument", filters.hasPlanDocument ? "true" : "false"); + } if (filters?.q) params.set("q", filters.q); if (filters?.limit) params.set("limit", String(filters.limit)); if (filters?.offset !== undefined) params.set("offset", String(filters.offset));