mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Fix updated issue list pagination sort
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
bfe6369ef5
commit
0550cd1bc8
6 files changed
+117
-9
No files matched your search
@@ -380,6 +380,46 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
||||
expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]);
|
||||
});
|
||||
|
||||
it("can page issues by most recently updated before priority", async () => {
|
||||
const companyId = randomUUID();
|
||||
const oldCriticalIssueId = randomUUID();
|
||||
const recentMediumIssueId = 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: oldCriticalIssueId,
|
||||
companyId,
|
||||
title: "Old critical issue",
|
||||
status: "todo",
|
||||
priority: "critical",
|
||||
updatedAt: new Date("2026-05-01T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: recentMediumIssueId,
|
||||
companyId,
|
||||
title: "Recent medium issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
updatedAt: new Date("2026-05-17T21:12:29.993Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
limit: 1,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
});
|
||||
|
||||
expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]);
|
||||
});
|
||||
|
||||
it("ranks comment matches ahead of description-only matches", async () => {
|
||||
const companyId = randomUUID();
|
||||
const commentMatchId = randomUUID();
|
||||
|
||||
@@ -1762,6 +1762,8 @@ export function issueRoutes(
|
||||
? Number.parseInt(rawOffset, 10)
|
||||
: null;
|
||||
const attention = req.query.attention as string | undefined;
|
||||
const sortField = req.query.sortField as string | undefined;
|
||||
const sortDir = req.query.sortDir as string | undefined;
|
||||
|
||||
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
||||
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
||||
@@ -1791,6 +1793,14 @@ export function issueRoutes(
|
||||
res.status(400).json({ error: "offset must be a non-negative integer" });
|
||||
return;
|
||||
}
|
||||
if (sortField !== undefined && sortField !== "updated") {
|
||||
res.status(400).json({ error: "sortField must be 'updated' when provided" });
|
||||
return;
|
||||
}
|
||||
if (sortDir !== undefined && sortDir !== "asc" && sortDir !== "desc") {
|
||||
res.status(400).json({ error: "sortDir must be 'asc' or 'desc' when provided" });
|
||||
return;
|
||||
}
|
||||
const offset = parsedOffset ?? 0;
|
||||
|
||||
const result = await svc.list(companyId, {
|
||||
@@ -1823,6 +1833,8 @@ export function issueRoutes(
|
||||
q: req.query.q as string | undefined,
|
||||
limit,
|
||||
offset,
|
||||
sortField: sortField === "updated" ? "updated" : undefined,
|
||||
sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined,
|
||||
});
|
||||
const issueIds = result.map((issue) => issue.id);
|
||||
const [handoffStates, recoveryActionByIssue] = await Promise.all([
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -239,6 +239,8 @@ export interface IssueFilters {
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortField?: "updated";
|
||||
sortDir?: "asc" | "desc";
|
||||
}
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
@@ -782,6 +784,43 @@ function latestIssueActivityAt(...values: Array<Date | string | null | undefined
|
||||
return normalized[0] ?? null;
|
||||
}
|
||||
|
||||
function issueListOrderBy(
|
||||
companyId: string,
|
||||
{
|
||||
hasSearch,
|
||||
priorityOrder,
|
||||
searchOrder,
|
||||
sortField,
|
||||
sortDir,
|
||||
}: {
|
||||
hasSearch: boolean;
|
||||
priorityOrder: SQL;
|
||||
searchOrder: SQL;
|
||||
sortField?: IssueFilters["sortField"];
|
||||
sortDir?: IssueFilters["sortDir"];
|
||||
},
|
||||
) {
|
||||
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
||||
if (sortField === "updated") {
|
||||
const activityOrder = sortDir === "asc"
|
||||
? asc(canonicalLastActivityAt)
|
||||
: desc(canonicalLastActivityAt);
|
||||
const updatedOrder = sortDir === "asc" ? asc(issues.updatedAt) : desc(issues.updatedAt);
|
||||
const idOrder = sortDir === "asc" ? asc(issues.id) : desc(issues.id);
|
||||
return hasSearch
|
||||
? [asc(searchOrder), activityOrder, updatedOrder, idOrder]
|
||||
: [activityOrder, updatedOrder, idOrder];
|
||||
}
|
||||
|
||||
return [
|
||||
hasSearch ? asc(searchOrder) : asc(priorityOrder),
|
||||
asc(priorityOrder),
|
||||
desc(canonicalLastActivityAt),
|
||||
desc(issues.updatedAt),
|
||||
desc(issues.id),
|
||||
];
|
||||
}
|
||||
|
||||
async function labelMapForIssues(dbOrTx: any, issueIds: string[]): Promise<Map<string, IssueLabelRow[]>> {
|
||||
const map = new Map<string, IssueLabelRow[]>();
|
||||
if (issueIds.length === 0) return map;
|
||||
@@ -3521,18 +3560,17 @@ export function issueService(db: Db) {
|
||||
ELSE 6
|
||||
END
|
||||
`;
|
||||
const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId);
|
||||
const baseQuery = db
|
||||
.select(issueListSelect)
|
||||
.from(issues)
|
||||
.where(and(...conditions))
|
||||
.orderBy(
|
||||
hasSearch ? asc(searchOrder) : asc(priorityOrder),
|
||||
asc(priorityOrder),
|
||||
desc(canonicalLastActivityAt),
|
||||
desc(issues.updatedAt),
|
||||
desc(issues.id),
|
||||
);
|
||||
.orderBy(...issueListOrderBy(companyId, {
|
||||
hasSearch,
|
||||
priorityOrder,
|
||||
searchOrder,
|
||||
sortField: filters?.sortField,
|
||||
sortDir: filters?.sortDir,
|
||||
}));
|
||||
const pageQuery = offset > 0
|
||||
? (limit === undefined ? baseQuery.offset(offset) : baseQuery.limit(limit).offset(offset))
|
||||
: (limit === undefined ? baseQuery : baseQuery.limit(limit));
|
||||
|
||||
@@ -51,6 +51,18 @@ describe("issuesApi.list", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes issue list sort options through to the company issues endpoint", async () => {
|
||||
await issuesApi.list("company-1", {
|
||||
limit: 500,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
});
|
||||
|
||||
expect(mockApi.get).toHaveBeenCalledWith(
|
||||
"/companies/company-1/issues?limit=500&sortField=updated&sortDir=desc",
|
||||
);
|
||||
});
|
||||
|
||||
it("posts recovery action resolution to the source issue endpoint", async () => {
|
||||
await issuesApi.resolveRecoveryAction("issue-1", {
|
||||
actionId: "00000000-0000-0000-0000-0000000000aa",
|
||||
|
||||
@@ -60,6 +60,8 @@ export const issuesApi = {
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortField?: "updated";
|
||||
sortDir?: "asc" | "desc";
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -86,6 +88,8 @@ export const issuesApi = {
|
||||
if (filters?.q) params.set("q", filters.q);
|
||||
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
||||
if (filters?.sortField) params.set("sortField", filters.sortField);
|
||||
if (filters?.sortDir) params.set("sortDir", filters.sortDir);
|
||||
const qs = params.toString();
|
||||
return api.get<Issue[]>(`/companies/${companyId}/issues${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
@@ -145,6 +145,8 @@ export function Issues() {
|
||||
includeRoutineExecutions: true,
|
||||
limit: issuePageSize,
|
||||
offset: pageParam,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
}),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
|
||||
|
||||
Reference in new issue
Block a user