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