fix: resolve orphan-sweep null-assignee filter regression (#8018)

> 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 <digitalflanker@gmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley
2026-06-12 09:20:00 -07:00
committed by GitHub
parent d9ea1bf9e1
commit 3fbab2e6db
5 changed files with 611 additions and 6 deletions
@@ -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);
});
});
@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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'",
});
});
});
+238
View File
@@ -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();