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:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user