Filter issues by plan document
This commit is contained in:
@@ -149,9 +149,11 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.delete(issueComments);
|
await db.delete(issueComments);
|
||||||
await db.delete(issueRelations);
|
await db.delete(issueRelations);
|
||||||
|
await db.delete(issueDocuments);
|
||||||
await db.delete(issueInboxArchives);
|
await db.delete(issueInboxArchives);
|
||||||
await db.delete(activityLog);
|
await db.delete(activityLog);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
await db.delete(documents);
|
||||||
await db.delete(executionWorkspaces);
|
await db.delete(executionWorkspaces);
|
||||||
await db.delete(projectWorkspaces);
|
await db.delete(projectWorkspaces);
|
||||||
await db.delete(projects);
|
await db.delete(projects);
|
||||||
@@ -386,6 +388,76 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||||||
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
|
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 () => {
|
it("can page issues by most recently updated before priority", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const oldCriticalIssueId = randomUUID();
|
const oldCriticalIssueId = randomUUID();
|
||||||
|
|||||||
@@ -1193,6 +1193,13 @@ export function issueRoutes(
|
|||||||
return value === true || value === "true" || value === "1";
|
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) {
|
function shouldIncludeDocumentAnnotations(req: Request) {
|
||||||
if (req.query.includeAnnotations === "false" || req.query.includeAnnotations === "0") return false;
|
if (req.query.includeAnnotations === "false" || req.query.includeAnnotations === "0") return false;
|
||||||
return req.actor.type === "agent" || parseBooleanQuery(req.query.includeAnnotations);
|
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 attention = req.query.attention as string | undefined;
|
||||||
const sortField = req.query.sortField as string | undefined;
|
const sortField = req.query.sortField as string | undefined;
|
||||||
const sortDir = req.query.sortDir 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")) {
|
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
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" });
|
res.status(400).json({ error: "sortDir must be 'asc' or 'desc' when provided" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasPlanDocument === null) {
|
||||||
|
res.status(400).json({ error: "hasPlanDocument must be true or false when provided" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const offset = parsedOffset ?? 0;
|
const offset = parsedOffset ?? 0;
|
||||||
|
|
||||||
const result = await svc.list(companyId, {
|
const result = await svc.list(companyId, {
|
||||||
@@ -2059,6 +2071,7 @@ export function issueRoutes(
|
|||||||
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
|
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
|
||||||
includeBlockedInboxAttention:
|
includeBlockedInboxAttention:
|
||||||
req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1",
|
req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1",
|
||||||
|
hasPlanDocument,
|
||||||
q: req.query.q as string | undefined,
|
q: req.query.q as string | undefined,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
@@ -2094,6 +2107,7 @@ export function issueRoutes(
|
|||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
const attention = req.query.attention as string | undefined;
|
const attention = req.query.attention as string | undefined;
|
||||||
|
const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument);
|
||||||
if (attention !== "blocked") {
|
if (attention !== "blocked") {
|
||||||
res.status(400).json({ error: "issues/count currently requires attention=blocked" });
|
res.status(400).json({ error: "issues/count currently requires attention=blocked" });
|
||||||
return;
|
return;
|
||||||
@@ -2102,6 +2116,10 @@ export function issueRoutes(
|
|||||||
res.status(400).json({ error: "issues/count does not accept limit or offset" });
|
res.status(400).json({ error: "issues/count does not accept limit or offset" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasPlanDocument === null) {
|
||||||
|
res.status(400).json({ error: "hasPlanDocument must be true or false when provided" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const count = await svc.count(companyId, {
|
const count = await svc.count(companyId, {
|
||||||
attention: "blocked",
|
attention: "blocked",
|
||||||
@@ -2126,6 +2144,7 @@ export function issueRoutes(
|
|||||||
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
|
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
|
||||||
includeBlockedBy: true,
|
includeBlockedBy: true,
|
||||||
includeBlockedInboxAttention: true,
|
includeBlockedInboxAttention: true,
|
||||||
|
hasPlanDocument,
|
||||||
q: req.query.q as string | undefined,
|
q: req.query.q as string | undefined,
|
||||||
});
|
});
|
||||||
res.json({ count });
|
res.json({ count });
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ export interface IssueFilters {
|
|||||||
includePluginOperations?: boolean;
|
includePluginOperations?: boolean;
|
||||||
includeBlockedBy?: boolean;
|
includeBlockedBy?: boolean;
|
||||||
includeBlockedInboxAttention?: boolean;
|
includeBlockedInboxAttention?: boolean;
|
||||||
|
hasPlanDocument?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: 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 {
|
function isoDate(value: Date | string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const date = value instanceof Date ? value : new Date(value);
|
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?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
||||||
if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
|
if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
|
||||||
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
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 (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition());
|
||||||
if (filters?.labelId) {
|
if (filters?.labelId) {
|
||||||
const labeledIssueIds = await dbOrTx
|
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?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
||||||
if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
|
if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
|
||||||
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
||||||
|
if (filters?.hasPlanDocument !== undefined) {
|
||||||
|
conditions.push(hasPlanDocumentCondition(companyId, filters.hasPlanDocument));
|
||||||
|
}
|
||||||
if (!shouldIncludePluginOperationIssues(filters)) {
|
if (!shouldIncludePluginOperationIssues(filters)) {
|
||||||
conditions.push(nonPluginOperationIssueCondition());
|
conditions.push(nonPluginOperationIssueCondition());
|
||||||
}
|
}
|
||||||
@@ -3989,6 +4009,9 @@ export function issueService(db: Db) {
|
|||||||
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind));
|
||||||
if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
|
if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`));
|
||||||
if (filters?.originId) conditions.push(eq(issues.originId, filters.originId));
|
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 (!shouldIncludePluginOperationIssues(filters)) conditions.push(nonPluginOperationIssueCondition());
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
|||||||
@@ -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 () => {
|
it("posts recovery action resolution to the source issue endpoint", async () => {
|
||||||
await issuesApi.resolveRecoveryAction("issue-1", {
|
await issuesApi.resolveRecoveryAction("issue-1", {
|
||||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const issuesApi = {
|
|||||||
includeRoutineExecutions?: boolean;
|
includeRoutineExecutions?: boolean;
|
||||||
includeBlockedBy?: boolean;
|
includeBlockedBy?: boolean;
|
||||||
includeBlockedInboxAttention?: boolean;
|
includeBlockedInboxAttention?: boolean;
|
||||||
|
hasPlanDocument?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
@@ -86,6 +87,9 @@ export const issuesApi = {
|
|||||||
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
|
||||||
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
||||||
if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "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?.q) params.set("q", filters.q);
|
||||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||||
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
||||||
|
|||||||
Reference in New Issue
Block a user