From 3fbab2e6dbe9e8215d7ce50f1b9e4c3826d6462f Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 12 Jun 2026 09:20:00 -0700 Subject: [PATCH] fix: resolve orphan-sweep null-assignee filter regression (#8018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > Resubmits #5925 by @digitalflanker-ux (rebased onto current `master`; original commit authorship preserved). ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The issues list API powers orphan-sweep and board inbox views that filter by assignee > - `assigneeAgentId=null` is a valid query-string sentinel for "unassigned issues" > - A regression caused that sentinel to throw 500 instead of filtering correctly > - This pull request restores null-sentinel parsing in the route and service layers > - The benefit is reliable orphan-sweep and unassigned-issue queries without server errors ## Linked Issues or Issue Description Refs #5891 (paired fix — land together) **Bug:** `GET /api/companies/:id/issues?assigneeAgentId=null` returned HTTP 500. Expected: HTTP 200 with only unassigned issues. Malformed UUIDs should return 4xx, not 500. ## What Changed - Parse `assigneeAgentId=null` in the issues list route and pass a JS `null` filter to the service - Handle malformed assignee IDs with HTTP 422 in the route layer - Extend `issueService.list` to treat `assigneeAgentId: null` as `IS NULL` SQL filter - Add route-level and service-level regression tests ## Verification - `pnpm exec vitest run server/src/__tests__/issue-list-assignee-filter-routes.test.ts server/src/__tests__/issues-service.test.ts` - result: 2 files passed, 79 tests passed ## Risks Low risk — scoped to query-parameter parsing and list filtering; no schema or API contract changes beyond fixing the regression. ## Model Used None — human-authored original fix by @digitalflanker-ux; rebased and test-harness adjustments by Paperclip cluster cleanup. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Cross-references and status (maintainer) - Pairs with #5891 — both fix the `assigneeAgentId=null` issues-list regression and should land together. - Supersedes #5925 (fork branch could not be force-pushed; this is the operator-mergeable resubmission). --------- Co-authored-by: openclaw Co-authored-by: Paperclip --- .../__tests__/issue-blocker-attention.test.ts | 84 +++++++ .../issue-list-assignee-filter-routes.test.ts | 223 ++++++++++++++++ server/src/__tests__/issues-service.test.ts | 238 ++++++++++++++++++ server/src/routes/issues.ts | 22 +- server/src/services/issues.ts | 50 +++- 5 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 server/src/__tests__/issue-list-assignee-filter-routes.test.ts 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) {