Filter issues by plan document

This commit is contained in:
Dotta
2026-05-26 13:01:44 +00:00
parent 6ac1a2e91f
commit 39e0fba65f
5 changed files with 126 additions and 0 deletions
@@ -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();
+19
View File
@@ -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 });
+23
View File
@@ -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<IssueRow, "id" | "identifier" | "title" | "status" |
};
}
function hasPlanDocumentCondition(companyId: string, hasPlanDocument: boolean): SQL {
const existsPlanDocument = sql<boolean>`
EXISTS (
SELECT 1
FROM ${issueDocuments}
WHERE ${issueDocuments.companyId} = ${companyId}
AND ${issueDocuments.issueId} = ${issues.id}
AND ${issueDocuments.key} = 'plan'
)
`;
return hasPlanDocument ? existsPlanDocument : sql<boolean>`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<number>`count(*)` })