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:
@@ -694,4 +694,88 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
|||||||
action: { label: "Choose disposition" },
|
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'",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -560,6 +560,244 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||||||
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
|
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 () => {
|
it("applies result limits to issue search", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
getClosedIsolatedExecutionWorkspaceMessage,
|
getClosedIsolatedExecutionWorkspaceMessage,
|
||||||
isClosedIsolatedExecutionWorkspace,
|
isClosedIsolatedExecutionWorkspace,
|
||||||
|
isUuidLike,
|
||||||
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
||||||
type CompanySearchQuery,
|
type CompanySearchQuery,
|
||||||
type CompanySearchResponse,
|
type CompanySearchResponse,
|
||||||
@@ -2447,6 +2448,8 @@ export function issueRoutes(
|
|||||||
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);
|
const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument);
|
||||||
|
const assigneeAgentFilterRaw = req.query.assigneeAgentId;
|
||||||
|
let assigneeAgentId: string | null | undefined;
|
||||||
|
|
||||||
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" });
|
||||||
@@ -2488,12 +2491,29 @@ export function issueRoutes(
|
|||||||
res.status(400).json({ error: "hasPlanDocument must be true or false when provided" });
|
res.status(400).json({ error: "hasPlanDocument must be true or false when provided" });
|
||||||
return;
|
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 offset = parsedOffset ?? 0;
|
||||||
|
|
||||||
const rawResult = await svc.list(companyId, {
|
const rawResult = await svc.list(companyId, {
|
||||||
attention: attention === "blocked" ? "blocked" : undefined,
|
attention: attention === "blocked" ? "blocked" : undefined,
|
||||||
status: req.query.status as string | string[] | undefined,
|
status: req.query.status as string | string[] | undefined,
|
||||||
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
assigneeAgentId,
|
||||||
participantAgentId: req.query.participantAgentId as string | undefined,
|
participantAgentId: req.query.participantAgentId as string | undefined,
|
||||||
assigneeUserId,
|
assigneeUserId,
|
||||||
touchedByUserId,
|
touchedByUserId,
|
||||||
|
|||||||
@@ -238,7 +238,16 @@ export function parseStatusFilter(input: string | readonly string[] | undefined)
|
|||||||
export interface IssueFilters {
|
export interface IssueFilters {
|
||||||
attention?: "blocked";
|
attention?: "blocked";
|
||||||
status?: string | readonly string[];
|
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;
|
participantAgentId?: string;
|
||||||
assigneeUserId?: string;
|
assigneeUserId?: string;
|
||||||
touchedByUserId?: string;
|
touchedByUserId?: string;
|
||||||
@@ -2855,6 +2864,21 @@ async function listIssueBlockedInboxAttentionMap(
|
|||||||
return result;
|
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(
|
async function blockedInboxIssueConditions(
|
||||||
dbOrTx: any,
|
dbOrTx: any,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -2894,7 +2918,13 @@ async function blockedInboxIssueConditions(
|
|||||||
if (statuses.length > 0) {
|
if (statuses.length > 0) {
|
||||||
conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]!) : inArray(issues.status, statuses));
|
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?.participantAgentId) conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
|
||||||
if (filters?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
if (filters?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
||||||
if (touchedByUserId) conditions.push(touchedByUserCondition(companyId, touchedByUserId));
|
if (touchedByUserId) conditions.push(touchedByUserCondition(companyId, touchedByUserId));
|
||||||
@@ -3924,6 +3954,8 @@ export function issueService(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const conditions = [eq(issues.companyId, companyId)];
|
const conditions = [eq(issues.companyId, companyId)];
|
||||||
|
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId);
|
||||||
|
assertValidAssigneeAgentFilter(assigneeAgentFilter);
|
||||||
const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit)
|
const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit)
|
||||||
? Math.max(1, Math.floor(filters.limit))
|
? Math.max(1, Math.floor(filters.limit))
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -3982,8 +4014,10 @@ export function issueService(db: Db) {
|
|||||||
} else if (statuses.length > 1) {
|
} else if (statuses.length > 1) {
|
||||||
conditions.push(inArray(issues.status, statuses));
|
conditions.push(inArray(issues.status, statuses));
|
||||||
}
|
}
|
||||||
if (filters?.assigneeAgentId) {
|
if (assigneeAgentFilter === null) {
|
||||||
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
|
conditions.push(isNull(issues.assigneeAgentId));
|
||||||
|
} else if (assigneeAgentFilter) {
|
||||||
|
conditions.push(eq(issues.assigneeAgentId, assigneeAgentFilter));
|
||||||
}
|
}
|
||||||
if (filters?.participantAgentId) {
|
if (filters?.participantAgentId) {
|
||||||
conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
|
conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId));
|
||||||
@@ -4164,7 +4198,13 @@ export function issueService(db: Db) {
|
|||||||
const statuses = parseStatusFilter(filters?.status);
|
const statuses = parseStatusFilter(filters?.status);
|
||||||
if (statuses.length === 1) conditions.push(eq(issues.status, statuses[0]!));
|
if (statuses.length === 1) conditions.push(eq(issues.status, statuses[0]!));
|
||||||
else if (statuses.length > 1) conditions.push(inArray(issues.status, statuses));
|
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?.assigneeUserId) conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId));
|
||||||
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
|
||||||
if (filters?.workspaceId) {
|
if (filters?.workspaceId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user