diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index 7fdb91e5..87608ad3 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -694,4 +694,88 @@ describeEmbeddedPostgres("issue blocker attention", () => { action: { label: "Choose disposition" }, }); }); + + it("applies assigneeAgentId='null' as an IS NULL filter on the blocked-inbox path", async () => { + const { companyId, agentId } = await createCompany("BAN"); + const unassignedParentId = await insertIssue({ + companyId, + identifier: "BAN-1", + title: "Unassigned blocked parent", + status: "blocked", + }); + const unassignedLeafId = await insertIssue({ + companyId, + identifier: "BAN-2", + title: "Unassigned leaf", + status: "todo", + }); + await block({ companyId, blockerIssueId: unassignedLeafId, blockedIssueId: unassignedParentId }); + + const assignedParentId = await insertIssue({ + companyId, + identifier: "BAN-3", + title: "Assigned blocked parent", + status: "blocked", + assigneeAgentId: agentId, + }); + const assignedLeafId = await insertIssue({ + companyId, + identifier: "BAN-4", + title: "Unassigned leaf for assigned parent", + status: "todo", + }); + await block({ companyId, blockerIssueId: assignedLeafId, blockedIssueId: assignedParentId }); + + const rows = await svc.list(companyId, { attention: "blocked", assigneeAgentId: "null" }); + expect(rows.map((row) => row.id)).toEqual([unassignedParentId]); + + await expect(svc.count(companyId, { attention: "blocked", assigneeAgentId: "null" })).resolves.toBe(1); + }); + + it("applies a UUID assigneeAgentId filter on the blocked-inbox path", async () => { + const { companyId, agentId } = await createCompany("BAU"); + const unassignedParentId = await insertIssue({ + companyId, + identifier: "BAU-1", + title: "Unassigned blocked parent", + status: "blocked", + }); + const unassignedLeafId = await insertIssue({ + companyId, + identifier: "BAU-2", + title: "Unassigned leaf", + status: "todo", + }); + await block({ companyId, blockerIssueId: unassignedLeafId, blockedIssueId: unassignedParentId }); + + const assignedParentId = await insertIssue({ + companyId, + identifier: "BAU-3", + title: "Assigned blocked parent", + status: "blocked", + assigneeAgentId: agentId, + }); + const assignedLeafId = await insertIssue({ + companyId, + identifier: "BAU-4", + title: "Unassigned leaf for assigned parent", + status: "todo", + }); + await block({ companyId, blockerIssueId: assignedLeafId, blockedIssueId: assignedParentId }); + + const rows = await svc.list(companyId, { attention: "blocked", assigneeAgentId: agentId }); + expect(rows.map((row) => row.id)).toEqual([assignedParentId]); + + await expect(svc.count(companyId, { attention: "blocked", assigneeAgentId: agentId })).resolves.toBe(1); + }); + + it("rejects malformed assigneeAgentId filter values on the blocked-inbox path", async () => { + const { companyId } = await createCompany("BAM"); + await expect( + svc.list(companyId, { attention: "blocked", assigneeAgentId: "not-a-uuid" }), + ).rejects.toThrow(/assigneeAgentId/i); + await expect( + svc.count(companyId, { attention: "blocked", assigneeAgentId: "not-a-uuid" }), + ).rejects.toThrow(/assigneeAgentId/i); + }); }); diff --git a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts new file mode 100644 index 00000000..977de540 --- /dev/null +++ b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts @@ -0,0 +1,223 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, companyMemberships, createDb, issues, principalPermissionGrants } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; +import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue list route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-list-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(agents); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp(companyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "cloud-user-1", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + source: "cloud_tenant", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes(db, {} as any)); + app.use(errorHandler); + return app; + } + + + function uniqueIssuePrefix() { + return `P${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`; + } + + async function seedCloudTenantMember(companyId: string) { + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: "cloud-user-1", + status: "active", + membershipRole: "owner", + updatedAt: new Date(), + }); + await ensureHumanRoleDefaultGrants(db, { + companyId, + principalId: "cloud-user-1", + membershipRole: "owner", + grantedByUserId: null, + }); + } + + it("returns only unassigned issues for assigneeAgentId=null", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const assignedIssueId = randomUUID(); + const unassignedIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values([ + { + id: assignedIssueId, + companyId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId, + }, + { + id: unassignedIssueId, + companyId, + title: "Unassigned issue", + status: "todo", + priority: "medium", + assigneeAgentId: null, + }, + ]); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "todo", assigneeAgentId: "null", limit: "20" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.map((issue: { id: string }) => issue.id)).toEqual([unassignedIssueId]); + }); + + it("keeps UUID assignee filtering behavior unchanged", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const otherAgentId = randomUUID(); + const assignedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + await db.insert(agents).values([ + { + id: assigneeAgentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId, + name: "Other", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + await db.insert(issues).values([ + { + id: assignedIssueId, + companyId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId, + }, + { + id: otherIssueId, + companyId, + title: "Other issue", + status: "todo", + priority: "medium", + assigneeAgentId: otherAgentId, + }, + ]); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "todo", assigneeAgentId, limit: "20" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.map((issue: { id: string }) => issue.id)).toEqual([assignedIssueId]); + }); + + it("returns 422 for malformed assigneeAgentId filters", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "todo", assigneeAgentId: "bad", limit: "20" }); + + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ + error: "assigneeAgentId must be a UUID or 'null'", + }); + }); +}); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 1c30899e..1c47f334 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -560,6 +560,244 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]); }); + it("treats assigneeAgentId='null' as an explicit unassigned filter", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const assignedIssueId = randomUUID(); + const unassignedIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values([ + { + id: assignedIssueId, + companyId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId, + }, + { + id: unassignedIssueId, + companyId, + title: "Unassigned issue", + status: "todo", + priority: "medium", + assigneeAgentId: null, + }, + ]); + + const result = await svc.list(companyId, { assigneeAgentId: "null" }); + expect(result.map((issue) => issue.id)).toEqual([unassignedIssueId]); + }); + + it("keeps UUID assignee filtering behavior unchanged", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const otherAgentId = randomUUID(); + const assignedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: assigneeAgentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId, + name: "Other", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + await db.insert(issues).values([ + { + id: assignedIssueId, + companyId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId, + }, + { + id: otherIssueId, + companyId, + title: "Other issue", + status: "todo", + priority: "medium", + assigneeAgentId: otherAgentId, + }, + ]); + + const result = await svc.list(companyId, { assigneeAgentId }); + expect(result.map((issue) => issue.id)).toEqual([assignedIssueId]); + }); + + it("rejects malformed assigneeAgentId filter values", async () => { + const companyId = 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: randomUUID(), + companyId, + title: "Any issue", + status: "todo", + priority: "medium", + }); + + await expect( + svc.list(companyId, { assigneeAgentId: "not-a-uuid" }), + ).rejects.toThrow(/assigneeAgentId/i); + }); + + it("counts only unassigned issues for assigneeAgentId='null'", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const assignedIssueId = randomUUID(); + const unassignedIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values([ + { + id: assignedIssueId, + companyId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId, + }, + { + id: unassignedIssueId, + companyId, + title: "Unassigned issue", + status: "todo", + priority: "medium", + assigneeAgentId: null, + }, + ]); + + await expect(svc.count(companyId, { assigneeAgentId: "null" })).resolves.toBe(1); + }); + + it("counts UUID-assigned issues with assigneeAgentId", async () => { + const companyId = randomUUID(); + const assigneeAgentId = randomUUID(); + const assignedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "Assignee", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values([ + { + id: assignedIssueId, + companyId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId, + }, + { + id: otherIssueId, + companyId, + title: "Other issue", + status: "todo", + priority: "medium", + }, + ]); + + await expect(svc.count(companyId, { assigneeAgentId })).resolves.toBe(1); + }); + + it("rejects malformed assigneeAgentId filter values in count", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await expect( + svc.count(companyId, { assigneeAgentId: "not-a-uuid" }), + ).rejects.toThrow(/assigneeAgentId/i); + }); + it("applies result limits to issue search", async () => { const companyId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 0462eb7c..75da8f59 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -52,6 +52,7 @@ import { updateIssueSchema, getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, + isUuidLike, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, type CompanySearchQuery, type CompanySearchResponse, @@ -2447,6 +2448,8 @@ export function issueRoutes( const sortField = req.query.sortField as string | undefined; const sortDir = req.query.sortDir as string | undefined; const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument); + const assigneeAgentFilterRaw = req.query.assigneeAgentId; + let assigneeAgentId: string | null | undefined; if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) { res.status(403).json({ error: "assigneeUserId=me requires board authentication" }); @@ -2488,12 +2491,29 @@ export function issueRoutes( res.status(400).json({ error: "hasPlanDocument must be true or false when provided" }); return; } + if (assigneeAgentFilterRaw !== undefined) { + if (typeof assigneeAgentFilterRaw !== "string") { + res.status(422).json({ error: "assigneeAgentId must be a UUID or 'null'" }); + return; + } + const normalizedAssigneeAgentFilter = assigneeAgentFilterRaw.trim(); + if (normalizedAssigneeAgentFilter.length === 0) { + assigneeAgentId = undefined; + } else if (normalizedAssigneeAgentFilter.toLowerCase() === "null") { + assigneeAgentId = null; + } else if (isUuidLike(normalizedAssigneeAgentFilter)) { + assigneeAgentId = normalizedAssigneeAgentFilter; + } else { + res.status(422).json({ error: "assigneeAgentId must be a UUID or 'null'" }); + return; + } + } const offset = parsedOffset ?? 0; const rawResult = await svc.list(companyId, { attention: attention === "blocked" ? "blocked" : undefined, status: req.query.status as string | string[] | undefined, - assigneeAgentId: req.query.assigneeAgentId as string | undefined, + assigneeAgentId, participantAgentId: req.query.participantAgentId as string | undefined, assigneeUserId, touchedByUserId, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 4a1ed03d..58132ccc 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -238,7 +238,16 @@ export function parseStatusFilter(input: string | readonly string[] | undefined) export interface IssueFilters { attention?: "blocked"; status?: string | readonly string[]; - assigneeAgentId?: string; + /** + * Filter by assignee agent ID. + * - `string` (UUID): match issues assigned to that agent. + * - `null`: match unassigned issues (IS NULL). + * - The literal string `"null"` is also accepted as a sentinel for `null` + * so that query-string callers can pass `?assigneeAgentId=null` directly. + * The route layer normalises it before calling the service, but the service + * also normalises it for direct callers. + */ + assigneeAgentId?: string | null; participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; @@ -2855,6 +2864,21 @@ async function listIssueBlockedInboxAttentionMap( return result; } +function parseIssueAssigneeAgentFilter( + assigneeAgentId: IssueFilters["assigneeAgentId"], +): string | null | undefined { + const normalizedRaw = typeof assigneeAgentId === "string" ? assigneeAgentId.trim() : assigneeAgentId; + const normalized = normalizedRaw === "" ? undefined : normalizedRaw; + if (typeof normalized !== "string") return normalized; + return normalized.toLowerCase() === "null" ? null : normalized; +} + +function assertValidAssigneeAgentFilter(assigneeAgentFilter: string | null | undefined) { + if (typeof assigneeAgentFilter === "string" && !isUuidLike(assigneeAgentFilter)) { + throw unprocessable("assigneeAgentId must be a UUID or 'null'"); + } +} + async function blockedInboxIssueConditions( dbOrTx: any, companyId: string, @@ -2894,7 +2918,13 @@ async function blockedInboxIssueConditions( if (statuses.length > 0) { conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]!) : inArray(issues.status, statuses)); } - if (filters?.assigneeAgentId) conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); + const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId); + assertValidAssigneeAgentFilter(assigneeAgentFilter); + if (assigneeAgentFilter === null) { + conditions.push(isNull(issues.assigneeAgentId)); + } else if (assigneeAgentFilter) { + conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter)); + } if (filters?.participantAgentId) conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId)); if (filters?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); if (touchedByUserId) conditions.push(touchedByUserCondition(companyId, touchedByUserId)); @@ -3924,6 +3954,8 @@ export function issueService(db: Db) { } const conditions = [eq(issues.companyId, companyId)]; + const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId); + assertValidAssigneeAgentFilter(assigneeAgentFilter); const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.floor(filters.limit)) : undefined; @@ -3982,8 +4014,10 @@ export function issueService(db: Db) { } else if (statuses.length > 1) { conditions.push(inArray(issues.status, statuses)); } - if (filters?.assigneeAgentId) { - conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); + if (assigneeAgentFilter === null) { + conditions.push(isNull(issues.assigneeAgentId)); + } else if (assigneeAgentFilter) { + conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter)); } if (filters?.participantAgentId) { conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId)); @@ -4164,7 +4198,13 @@ export function issueService(db: Db) { const statuses = parseStatusFilter(filters?.status); if (statuses.length === 1) conditions.push(eq(issues.status, statuses[0]!)); else if (statuses.length > 1) conditions.push(inArray(issues.status, statuses)); - if (filters?.assigneeAgentId) conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); + const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId); + assertValidAssigneeAgentFilter(assigneeAgentFilter); + if (assigneeAgentFilter === null) { + conditions.push(isNull(issues.assigneeAgentId)); + } else if (assigneeAgentFilter) { + conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter)); + } if (filters?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); if (filters?.workspaceId) {